From 36d22d82aa202bb199967e9512281e9a53db42c9 Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Sun, 7 Apr 2024 21:33:14 +0200 Subject: Adding upstream version 115.7.0esr. Signed-off-by: Daniel Baumann --- .../components/sessionstore/ContentRestore.sys.mjs | 435 ++ .../sessionstore/ContentSessionStore.sys.mjs | 686 ++ .../components/sessionstore/GlobalState.sys.mjs | 88 + .../RecentlyClosedTabsAndWindowsMenuUtils.sys.mjs | 254 + browser/components/sessionstore/RunState.sys.mjs | 92 + .../components/sessionstore/SessionCookies.sys.mjs | 293 + .../components/sessionstore/SessionFile.sys.mjs | 467 ++ .../sessionstore/SessionMigration.sys.mjs | 92 + .../components/sessionstore/SessionSaver.sys.mjs | 405 ++ .../components/sessionstore/SessionStartup.sys.mjs | 415 ++ .../components/sessionstore/SessionStore.sys.mjs | 6932 ++++++++++++++++++++ .../components/sessionstore/SessionWriter.sys.mjs | 396 ++ .../sessionstore/StartupPerformance.sys.mjs | 242 + .../components/sessionstore/TabAttributes.sys.mjs | 72 + browser/components/sessionstore/TabState.sys.mjs | 204 + .../components/sessionstore/TabStateCache.sys.mjs | 171 + .../sessionstore/TabStateFlusher.sys.mjs | 234 + .../sessionstore/content/aboutSessionRestore.js | 450 ++ .../sessionstore/content/aboutSessionRestore.xhtml | 69 + .../sessionstore/content/content-sessionStore.js | 13 + browser/components/sessionstore/jar.mn | 8 + browser/components/sessionstore/moz.build | 33 + browser/components/sessionstore/test/browser.ini | 390 ++ .../sessionstore/test/browser_1234021.js | 22 + .../sessionstore/test/browser_1234021_page.html | 6 + .../test/browser_1284886_suspend_tab.html | 12 + .../test/browser_1284886_suspend_tab.js | 95 + .../test/browser_1284886_suspend_tab_2.html | 11 + .../test/browser_1446343-windowsize.js | 39 + .../test/browser_248970_b_perwindowpb.js | 198 + .../sessionstore/test/browser_248970_b_sample.html | 37 + .../components/sessionstore/test/browser_339445.js | 39 + .../sessionstore/test/browser_339445_sample.html | 18 + .../components/sessionstore/test/browser_345898.js | 69 + .../components/sessionstore/test/browser_350525.js | 135 + .../test/browser_354894_perwindowpb.js | 489 ++ .../components/sessionstore/test/browser_367052.js | 48 + .../components/sessionstore/test/browser_393716.js | 103 + .../sessionstore/test/browser_394759_basic.js | 123 + .../sessionstore/test/browser_394759_behavior.js | 91 + .../test/browser_394759_perwindowpb.js | 57 + .../sessionstore/test/browser_394759_purge.js | 247 + .../components/sessionstore/test/browser_423132.js | 52 + .../sessionstore/test/browser_423132_sample.html | 14 + .../components/sessionstore/test/browser_447951.js | 84 + .../sessionstore/test/browser_447951_sample.html | 5 + .../components/sessionstore/test/browser_454908.js | 65 + .../sessionstore/test/browser_454908_sample.html | 8 + .../components/sessionstore/test/browser_456342.js | 90 + .../sessionstore/test/browser_456342_sample.xhtml | 46 + .../components/sessionstore/test/browser_459906.js | 79 + .../sessionstore/test/browser_459906_empty.html | 3 + .../sessionstore/test/browser_459906_sample.html | 41 + .../components/sessionstore/test/browser_461634.js | 133 + .../components/sessionstore/test/browser_461743.js | 53 + .../sessionstore/test/browser_461743_sample.html | 56 + .../components/sessionstore/test/browser_463205.js | 40 + .../sessionstore/test/browser_463205_sample.html | 7 + .../components/sessionstore/test/browser_463206.js | 120 + .../sessionstore/test/browser_463206_sample.html | 11 + .../components/sessionstore/test/browser_464199.js | 176 + .../sessionstore/test/browser_464620_a.html | 54 + .../sessionstore/test/browser_464620_a.js | 64 + .../sessionstore/test/browser_464620_b.html | 57 + .../sessionstore/test/browser_464620_b.js | 64 + .../sessionstore/test/browser_464620_xd.html | 5 + .../components/sessionstore/test/browser_465215.js | 36 + .../components/sessionstore/test/browser_465223.js | 51 + .../components/sessionstore/test/browser_466937.js | 51 + .../sessionstore/test/browser_466937_sample.html | 20 + .../test/browser_467409-backslashplosion.js | 88 + .../components/sessionstore/test/browser_477657.js | 80 + .../components/sessionstore/test/browser_480893.js | 45 + .../components/sessionstore/test/browser_485482.js | 76 + .../sessionstore/test/browser_485482_sample.html | 12 + .../components/sessionstore/test/browser_485563.js | 33 + .../components/sessionstore/test/browser_490040.js | 105 + .../components/sessionstore/test/browser_491168.js | 112 + .../components/sessionstore/test/browser_491577.js | 212 + .../components/sessionstore/test/browser_495495.js | 47 + .../components/sessionstore/test/browser_500328.js | 132 + .../components/sessionstore/test/browser_506482.js | 78 + .../components/sessionstore/test/browser_514751.js | 41 + .../components/sessionstore/test/browser_522375.js | 22 + .../components/sessionstore/test/browser_522545.js | 443 ++ .../components/sessionstore/test/browser_524745.js | 55 + .../components/sessionstore/test/browser_526613.js | 86 + .../components/sessionstore/test/browser_528776.js | 27 + .../components/sessionstore/test/browser_579868.js | 31 + .../components/sessionstore/test/browser_579879.js | 31 + .../components/sessionstore/test/browser_580512.js | 117 + .../components/sessionstore/test/browser_581937.js | 22 + .../sessionstore/test/browser_586068-apptabs.js | 109 + .../test/browser_586068-apptabs_ondemand.js | 105 + .../browser_586068-browser_state_interrupted.js | 212 + .../sessionstore/test/browser_586068-cascade.js | 107 + .../test/browser_586068-multi_window.js | 115 + .../sessionstore/test/browser_586068-reload.js | 118 + .../sessionstore/test/browser_586068-select.js | 128 + .../test/browser_586068-window_state.js | 120 + .../test/browser_586068-window_state_override.js | 118 + .../components/sessionstore/test/browser_586147.js | 52 + .../components/sessionstore/test/browser_588426.js | 62 + .../components/sessionstore/test/browser_589246.js | 286 + .../components/sessionstore/test/browser_590268.js | 155 + .../components/sessionstore/test/browser_590563.js | 120 + .../test/browser_595601-restore_hidden.js | 165 + .../components/sessionstore/test/browser_597071.js | 36 + .../components/sessionstore/test/browser_600545.js | 123 + .../components/sessionstore/test/browser_601955.js | 54 + .../components/sessionstore/test/browser_607016.js | 155 + ...ser_615394-SSWindowState_events_duplicateTab.js | 69 + ..._615394-SSWindowState_events_setBrowserState.js | 152 + ...wser_615394-SSWindowState_events_setTabState.js | 61 + ...r_615394-SSWindowState_events_setWindowState.js | 65 + ...ser_615394-SSWindowState_events_undoCloseTab.js | 65 + ..._615394-SSWindowState_events_undoCloseWindow.js | 146 + .../components/sessionstore/test/browser_618151.js | 67 + .../components/sessionstore/test/browser_623779.js | 13 + .../components/sessionstore/test/browser_624727.js | 33 + .../components/sessionstore/test/browser_625016.js | 103 + .../components/sessionstore/test/browser_628270.js | 43 + .../components/sessionstore/test/browser_635418.js | 58 + .../components/sessionstore/test/browser_636279.js | 140 + .../components/sessionstore/test/browser_637020.js | 74 + .../sessionstore/test/browser_637020_slow.sjs | 22 + .../components/sessionstore/test/browser_645428.js | 22 + .../components/sessionstore/test/browser_659591.js | 39 + .../components/sessionstore/test/browser_662743.js | 138 + .../sessionstore/test/browser_662743_sample.html | 15 + .../components/sessionstore/test/browser_662812.js | 43 + .../test/browser_665702-state_session.js | 26 + .../components/sessionstore/test/browser_682507.js | 22 + .../components/sessionstore/test/browser_687710.js | 58 + .../sessionstore/test/browser_687710_2.js | 100 + .../components/sessionstore/test/browser_694378.js | 41 + .../components/sessionstore/test/browser_701377.js | 56 + .../components/sessionstore/test/browser_705597.js | 85 + .../components/sessionstore/test/browser_707862.js | 95 + .../components/sessionstore/test/browser_739531.js | 55 + .../sessionstore/test/browser_739531_frame.html | 1 + .../sessionstore/test/browser_739531_sample.html | 23 + .../components/sessionstore/test/browser_739805.js | 49 + .../test/browser_819510_perwindowpb.js | 152 + .../sessionstore/test/browser_906076_lazy_tabs.js | 163 + .../components/sessionstore/test/browser_911547.js | 82 + .../sessionstore/test/browser_911547_sample.html | 18 + .../test/browser_911547_sample.html^headers^ | 1 + .../test/browser_aboutPrivateBrowsing.js | 24 + .../test/browser_aboutSessionRestore.js | 67 + .../test/browser_async_duplicate_tab.js | 87 + .../sessionstore/test/browser_async_flushes.js | 131 + .../sessionstore/test/browser_async_remove_tab.js | 209 + .../test/browser_async_window_flushing.js | 208 + .../sessionstore/test/browser_attributes.js | 83 + .../test/browser_background_tab_crash.js | 262 + .../sessionstore/test/browser_backup_recovery.js | 307 + .../sessionstore/test/browser_bfcache_telemetry.js | 45 + .../sessionstore/test/browser_broadcast.js | 162 + .../sessionstore/test/browser_capabilities.js | 90 + .../sessionstore/test/browser_cleaner.js | 214 + .../sessionstore/test/browser_closedId.js | 109 + ...er_closed_objects_changed_notifications_tabs.js | 136 + ...closed_objects_changed_notifications_windows.js | 129 + .../test/browser_closed_tabs_windows.js | 191 + .../sessionstore/test/browser_cookies.js | 81 + .../sessionstore/test/browser_cookies_legacy.js | 75 + .../sessionstore/test/browser_cookies_privacy.js | 125 + .../sessionstore/test/browser_cookies_sameSite.js | 89 + .../sessionstore/test/browser_crashedTabs.js | 501 ++ .../test/browser_docshell_uuid_consistency.js | 104 + .../sessionstore/test/browser_duplicate_history.js | 30 + .../test/browser_duplicate_tab_in_new_window.js | 37 + .../sessionstore/test/browser_dying_cache.js | 80 + .../sessionstore/test/browser_dynamic_frames.js | 105 + .../test/browser_firefoxView_restore.js | 39 + .../test/browser_firefoxView_selected_restore.js | 88 + .../test/browser_focus_after_restore.js | 34 + .../test/browser_forget_async_closings.js | 163 + .../sessionstore/test/browser_formdata.js | 227 + .../sessionstore/test/browser_formdata_cc.js | 107 + .../sessionstore/test/browser_formdata_format.js | 170 + .../test/browser_formdata_format_sample.html | 7 + .../sessionstore/test/browser_formdata_max_size.js | 131 + .../sessionstore/test/browser_formdata_password.js | 69 + .../sessionstore/test/browser_formdata_sample.html | 20 + .../sessionstore/test/browser_formdata_xpath.js | 242 + .../test/browser_formdata_xpath_sample.html | 37 + .../sessionstore/test/browser_frame_history.js | 230 + .../sessionstore/test/browser_frame_history_a.html | 5 + .../sessionstore/test/browser_frame_history_b.html | 10 + .../sessionstore/test/browser_frame_history_c.html | 5 + .../test/browser_frame_history_c1.html | 5 + .../test/browser_frame_history_c2.html | 5 + .../test/browser_frame_history_index.html | 9 + .../test/browser_frame_history_index2.html | 3 + .../test/browser_frame_history_index_blank.html | 4 + .../sessionstore/test/browser_frametree.js | 134 + .../test/browser_frametree_sample.html | 8 + .../test/browser_frametree_sample_frameset.html | 11 + .../test/browser_frametree_sample_iframes.html | 9 + .../sessionstore/test/browser_global_store.js | 53 + .../sessionstore/test/browser_history_persist.js | 163 + .../test/browser_ignore_updates_crashed_tabs.js | 108 + .../sessionstore/test/browser_label_and_icon.js | 53 + .../sessionstore/test/browser_merge_closed_tabs.js | 116 + .../test/browser_movePendingTabToNewWindow.js | 124 + .../test/browser_multiple_navigateAndRestore.js | 45 + .../test/browser_multiple_select_after_load.js | 54 + .../test/browser_newtab_userTypedValue.js | 92 + .../test/browser_not_collect_when_idle.js | 118 + .../sessionstore/test/browser_old_favicon.js | 57 + .../sessionstore/test/browser_page_title.js | 54 + .../test/browser_parentProcessRestoreHash.js | 115 + .../sessionstore/test/browser_pending_tabs.js | 38 + .../sessionstore/test/browser_pinned_tabs.js | 324 + .../sessionstore/test/browser_privatetabs.js | 33 + .../sessionstore/test/browser_purge_shistory.js | 65 + .../test/browser_remoteness_flip_on_restore.js | 310 + .../test/browser_reopen_all_windows.js | 146 + .../sessionstore/test/browser_replace_load.js | 56 + .../test/browser_restoreTabContainer.js | 81 + .../test/browser_restore_container_tabs_oa.js | 231 + .../browser_restore_cookies_noOriginAttributes.js | 191 + .../test/browser_restore_pageProxyState.js | 77 + .../test/browser_restore_private_tab_os.js | 59 + .../sessionstore/test/browser_restore_redirect.js | 72 + .../test/browser_restore_reversed_z_order.js | 125 + .../browser_restore_session_in_undoCloseTab.js | 56 + .../sessionstore/test/browser_restore_srcdoc.js | 44 + .../test/browser_restore_tabless_window.js | 56 + .../test/browser_restored_window_features.js | 164 + .../test/browser_revive_crashed_bg_tabs.js | 59 + .../sessionstore/test/browser_scrollPositions.js | 258 + .../test/browser_scrollPositionsReaderMode.js | 76 + .../browser_scrollPositions_readerModeArticle.html | 26 + .../test/browser_scrollPositions_sample.html | 8 + .../test/browser_scrollPositions_sample2.html | 8 + .../browser_scrollPositions_sample_frameset.html | 11 + .../test/browser_send_async_message_oom.js | 75 + .../sessionstore/test/browser_sessionHistory.js | 331 + .../test/browser_sessionHistory_slow.sjs | 22 + .../sessionstore/test/browser_sessionStorage.html | 28 + .../sessionstore/test/browser_sessionStorage.js | 298 + .../test/browser_sessionStorage_size.js | 34 + .../test/browser_sessionStoreContainer.js | 165 + .../test/browser_sizemodeBeforeMinimized.js | 44 + .../test/browser_speculative_connect.html | 8 + .../test/browser_speculative_connect.js | 145 + .../sessionstore/test/browser_swapDocShells.js | 40 + .../sessionstore/test/browser_switch_remoteness.js | 53 + .../test/browser_tab_label_during_restore.js | 182 + .../test/browser_tabicon_after_bg_tab_crash.js | 52 + .../sessionstore/test/browser_tabs_in_urlbar.js | 151 + .../sessionstore/test/browser_undoCloseById.js | 174 + .../test/browser_unrestored_crashedTabs.js | 73 + .../sessionstore/test/browser_upgrade_backup.js | 158 + .../sessionstore/test/browser_urlbarSearchMode.js | 57 + .../browser_userTyped_restored_after_discard.js | 47 + .../test/browser_windowRestore_perwindowpb.js | 32 + .../test/browser_windowStateContainer.js | 176 + .../sessionstore/test/coopHeaderCommon.sjs | 31 + .../components/sessionstore/test/coop_coep.html | 6 + .../sessionstore/test/coop_coep.html^headers^ | 2 + browser/components/sessionstore/test/empty.html | 6 + .../test/file_async_duplicate_tab.html | 1 + .../sessionstore/test/file_async_flushes.html | 1 + .../sessionstore/test/file_formdata_password.html | 17 + .../test/file_sessionHistory_hashchange.html | 1 + browser/components/sessionstore/test/head.js | 782 +++ .../sessionstore/test/marionette/manifest.ini | 14 + .../test/marionette/session_store_test_case.py | 432 ++ .../test/marionette/test_restore_loading_tab.py | 69 + .../test_restore_manually_with_pinned_tabs.py | 108 + .../test_restore_windows_after_close_last_tabs.py | 59 + .../test_restore_windows_after_restart_and_quit.py | 82 + .../test_restore_windows_after_windows_shutdown.py | 66 + .../sessionstore/test/restore_redirect_http.html | 0 .../test/restore_redirect_http.html^headers^ | 2 + .../sessionstore/test/restore_redirect_js.html | 10 + .../sessionstore/test/restore_redirect_target.html | 8 + .../test/unit/data/sessionCheckpoints_all.json | 11 + .../test/unit/data/sessionstore_invalid.js | 3 + .../test/unit/data/sessionstore_valid.js | 3 + browser/components/sessionstore/test/unit/head.js | 36 + .../sessionstore/test/unit/test_backup_once.js | 137 + .../test/unit/test_final_write_cleanup.js | 118 + .../test/unit/test_histogram_corrupt_files.js | 117 + .../test/unit/test_migration_lz4compression.js | 151 + .../test/unit/test_startup_invalid_session.js | 27 + .../test/unit/test_startup_nosession_async.js | 19 + .../test/unit/test_startup_session_async.js | 32 + .../components/sessionstore/test/unit/xpcshell.ini | 21 + 293 files changed, 36291 insertions(+) create mode 100644 browser/components/sessionstore/ContentRestore.sys.mjs create mode 100644 browser/components/sessionstore/ContentSessionStore.sys.mjs create mode 100644 browser/components/sessionstore/GlobalState.sys.mjs create mode 100644 browser/components/sessionstore/RecentlyClosedTabsAndWindowsMenuUtils.sys.mjs create mode 100644 browser/components/sessionstore/RunState.sys.mjs create mode 100644 browser/components/sessionstore/SessionCookies.sys.mjs create mode 100644 browser/components/sessionstore/SessionFile.sys.mjs create mode 100644 browser/components/sessionstore/SessionMigration.sys.mjs create mode 100644 browser/components/sessionstore/SessionSaver.sys.mjs create mode 100644 browser/components/sessionstore/SessionStartup.sys.mjs create mode 100644 browser/components/sessionstore/SessionStore.sys.mjs create mode 100644 browser/components/sessionstore/SessionWriter.sys.mjs create mode 100644 browser/components/sessionstore/StartupPerformance.sys.mjs create mode 100644 browser/components/sessionstore/TabAttributes.sys.mjs create mode 100644 browser/components/sessionstore/TabState.sys.mjs create mode 100644 browser/components/sessionstore/TabStateCache.sys.mjs create mode 100644 browser/components/sessionstore/TabStateFlusher.sys.mjs create mode 100644 browser/components/sessionstore/content/aboutSessionRestore.js create mode 100644 browser/components/sessionstore/content/aboutSessionRestore.xhtml create mode 100644 browser/components/sessionstore/content/content-sessionStore.js create mode 100644 browser/components/sessionstore/jar.mn create mode 100644 browser/components/sessionstore/moz.build create mode 100644 browser/components/sessionstore/test/browser.ini create mode 100644 browser/components/sessionstore/test/browser_1234021.js create mode 100644 browser/components/sessionstore/test/browser_1234021_page.html create mode 100644 browser/components/sessionstore/test/browser_1284886_suspend_tab.html create mode 100644 browser/components/sessionstore/test/browser_1284886_suspend_tab.js create mode 100644 browser/components/sessionstore/test/browser_1284886_suspend_tab_2.html create mode 100644 browser/components/sessionstore/test/browser_1446343-windowsize.js create mode 100644 browser/components/sessionstore/test/browser_248970_b_perwindowpb.js create mode 100644 browser/components/sessionstore/test/browser_248970_b_sample.html create mode 100644 browser/components/sessionstore/test/browser_339445.js create mode 100644 browser/components/sessionstore/test/browser_339445_sample.html create mode 100644 browser/components/sessionstore/test/browser_345898.js create mode 100644 browser/components/sessionstore/test/browser_350525.js create mode 100644 browser/components/sessionstore/test/browser_354894_perwindowpb.js create mode 100644 browser/components/sessionstore/test/browser_367052.js create mode 100644 browser/components/sessionstore/test/browser_393716.js create mode 100644 browser/components/sessionstore/test/browser_394759_basic.js create mode 100644 browser/components/sessionstore/test/browser_394759_behavior.js create mode 100644 browser/components/sessionstore/test/browser_394759_perwindowpb.js create mode 100644 browser/components/sessionstore/test/browser_394759_purge.js create mode 100644 browser/components/sessionstore/test/browser_423132.js create mode 100644 browser/components/sessionstore/test/browser_423132_sample.html create mode 100644 browser/components/sessionstore/test/browser_447951.js create mode 100644 browser/components/sessionstore/test/browser_447951_sample.html create mode 100644 browser/components/sessionstore/test/browser_454908.js create mode 100644 browser/components/sessionstore/test/browser_454908_sample.html create mode 100644 browser/components/sessionstore/test/browser_456342.js create mode 100644 browser/components/sessionstore/test/browser_456342_sample.xhtml create mode 100644 browser/components/sessionstore/test/browser_459906.js create mode 100644 browser/components/sessionstore/test/browser_459906_empty.html create mode 100644 browser/components/sessionstore/test/browser_459906_sample.html create mode 100644 browser/components/sessionstore/test/browser_461634.js create mode 100644 browser/components/sessionstore/test/browser_461743.js create mode 100644 browser/components/sessionstore/test/browser_461743_sample.html create mode 100644 browser/components/sessionstore/test/browser_463205.js create mode 100644 browser/components/sessionstore/test/browser_463205_sample.html create mode 100644 browser/components/sessionstore/test/browser_463206.js create mode 100644 browser/components/sessionstore/test/browser_463206_sample.html create mode 100644 browser/components/sessionstore/test/browser_464199.js create mode 100644 browser/components/sessionstore/test/browser_464620_a.html create mode 100644 browser/components/sessionstore/test/browser_464620_a.js create mode 100644 browser/components/sessionstore/test/browser_464620_b.html create mode 100644 browser/components/sessionstore/test/browser_464620_b.js create mode 100644 browser/components/sessionstore/test/browser_464620_xd.html create mode 100644 browser/components/sessionstore/test/browser_465215.js create mode 100644 browser/components/sessionstore/test/browser_465223.js create mode 100644 browser/components/sessionstore/test/browser_466937.js create mode 100644 browser/components/sessionstore/test/browser_466937_sample.html create mode 100644 browser/components/sessionstore/test/browser_467409-backslashplosion.js create mode 100644 browser/components/sessionstore/test/browser_477657.js create mode 100644 browser/components/sessionstore/test/browser_480893.js create mode 100644 browser/components/sessionstore/test/browser_485482.js create mode 100644 browser/components/sessionstore/test/browser_485482_sample.html create mode 100644 browser/components/sessionstore/test/browser_485563.js create mode 100644 browser/components/sessionstore/test/browser_490040.js create mode 100644 browser/components/sessionstore/test/browser_491168.js create mode 100644 browser/components/sessionstore/test/browser_491577.js create mode 100644 browser/components/sessionstore/test/browser_495495.js create mode 100644 browser/components/sessionstore/test/browser_500328.js create mode 100644 browser/components/sessionstore/test/browser_506482.js create mode 100644 browser/components/sessionstore/test/browser_514751.js create mode 100644 browser/components/sessionstore/test/browser_522375.js create mode 100644 browser/components/sessionstore/test/browser_522545.js create mode 100644 browser/components/sessionstore/test/browser_524745.js create mode 100644 browser/components/sessionstore/test/browser_526613.js create mode 100644 browser/components/sessionstore/test/browser_528776.js create mode 100644 browser/components/sessionstore/test/browser_579868.js create mode 100644 browser/components/sessionstore/test/browser_579879.js create mode 100644 browser/components/sessionstore/test/browser_580512.js create mode 100644 browser/components/sessionstore/test/browser_581937.js create mode 100644 browser/components/sessionstore/test/browser_586068-apptabs.js create mode 100644 browser/components/sessionstore/test/browser_586068-apptabs_ondemand.js create mode 100644 browser/components/sessionstore/test/browser_586068-browser_state_interrupted.js create mode 100644 browser/components/sessionstore/test/browser_586068-cascade.js create mode 100644 browser/components/sessionstore/test/browser_586068-multi_window.js create mode 100644 browser/components/sessionstore/test/browser_586068-reload.js create mode 100644 browser/components/sessionstore/test/browser_586068-select.js create mode 100644 browser/components/sessionstore/test/browser_586068-window_state.js create mode 100644 browser/components/sessionstore/test/browser_586068-window_state_override.js create mode 100644 browser/components/sessionstore/test/browser_586147.js create mode 100644 browser/components/sessionstore/test/browser_588426.js create mode 100644 browser/components/sessionstore/test/browser_589246.js create mode 100644 browser/components/sessionstore/test/browser_590268.js create mode 100644 browser/components/sessionstore/test/browser_590563.js create mode 100644 browser/components/sessionstore/test/browser_595601-restore_hidden.js create mode 100644 browser/components/sessionstore/test/browser_597071.js create mode 100644 browser/components/sessionstore/test/browser_600545.js create mode 100644 browser/components/sessionstore/test/browser_601955.js create mode 100644 browser/components/sessionstore/test/browser_607016.js create mode 100644 browser/components/sessionstore/test/browser_615394-SSWindowState_events_duplicateTab.js create mode 100644 browser/components/sessionstore/test/browser_615394-SSWindowState_events_setBrowserState.js create mode 100644 browser/components/sessionstore/test/browser_615394-SSWindowState_events_setTabState.js create mode 100644 browser/components/sessionstore/test/browser_615394-SSWindowState_events_setWindowState.js create mode 100644 browser/components/sessionstore/test/browser_615394-SSWindowState_events_undoCloseTab.js create mode 100644 browser/components/sessionstore/test/browser_615394-SSWindowState_events_undoCloseWindow.js create mode 100644 browser/components/sessionstore/test/browser_618151.js create mode 100644 browser/components/sessionstore/test/browser_623779.js create mode 100644 browser/components/sessionstore/test/browser_624727.js create mode 100644 browser/components/sessionstore/test/browser_625016.js create mode 100644 browser/components/sessionstore/test/browser_628270.js create mode 100644 browser/components/sessionstore/test/browser_635418.js create mode 100644 browser/components/sessionstore/test/browser_636279.js create mode 100644 browser/components/sessionstore/test/browser_637020.js create mode 100644 browser/components/sessionstore/test/browser_637020_slow.sjs create mode 100644 browser/components/sessionstore/test/browser_645428.js create mode 100644 browser/components/sessionstore/test/browser_659591.js create mode 100644 browser/components/sessionstore/test/browser_662743.js create mode 100644 browser/components/sessionstore/test/browser_662743_sample.html create mode 100644 browser/components/sessionstore/test/browser_662812.js create mode 100644 browser/components/sessionstore/test/browser_665702-state_session.js create mode 100644 browser/components/sessionstore/test/browser_682507.js create mode 100644 browser/components/sessionstore/test/browser_687710.js create mode 100644 browser/components/sessionstore/test/browser_687710_2.js create mode 100644 browser/components/sessionstore/test/browser_694378.js create mode 100644 browser/components/sessionstore/test/browser_701377.js create mode 100644 browser/components/sessionstore/test/browser_705597.js create mode 100644 browser/components/sessionstore/test/browser_707862.js create mode 100644 browser/components/sessionstore/test/browser_739531.js create mode 100644 browser/components/sessionstore/test/browser_739531_frame.html create mode 100644 browser/components/sessionstore/test/browser_739531_sample.html create mode 100644 browser/components/sessionstore/test/browser_739805.js create mode 100644 browser/components/sessionstore/test/browser_819510_perwindowpb.js create mode 100644 browser/components/sessionstore/test/browser_906076_lazy_tabs.js create mode 100644 browser/components/sessionstore/test/browser_911547.js create mode 100644 browser/components/sessionstore/test/browser_911547_sample.html create mode 100644 browser/components/sessionstore/test/browser_911547_sample.html^headers^ create mode 100644 browser/components/sessionstore/test/browser_aboutPrivateBrowsing.js create mode 100644 browser/components/sessionstore/test/browser_aboutSessionRestore.js create mode 100644 browser/components/sessionstore/test/browser_async_duplicate_tab.js create mode 100644 browser/components/sessionstore/test/browser_async_flushes.js create mode 100644 browser/components/sessionstore/test/browser_async_remove_tab.js create mode 100644 browser/components/sessionstore/test/browser_async_window_flushing.js create mode 100644 browser/components/sessionstore/test/browser_attributes.js create mode 100644 browser/components/sessionstore/test/browser_background_tab_crash.js create mode 100644 browser/components/sessionstore/test/browser_backup_recovery.js create mode 100644 browser/components/sessionstore/test/browser_bfcache_telemetry.js create mode 100644 browser/components/sessionstore/test/browser_broadcast.js create mode 100644 browser/components/sessionstore/test/browser_capabilities.js create mode 100644 browser/components/sessionstore/test/browser_cleaner.js create mode 100644 browser/components/sessionstore/test/browser_closedId.js create mode 100644 browser/components/sessionstore/test/browser_closed_objects_changed_notifications_tabs.js create mode 100644 browser/components/sessionstore/test/browser_closed_objects_changed_notifications_windows.js create mode 100644 browser/components/sessionstore/test/browser_closed_tabs_windows.js create mode 100644 browser/components/sessionstore/test/browser_cookies.js create mode 100644 browser/components/sessionstore/test/browser_cookies_legacy.js create mode 100644 browser/components/sessionstore/test/browser_cookies_privacy.js create mode 100644 browser/components/sessionstore/test/browser_cookies_sameSite.js create mode 100644 browser/components/sessionstore/test/browser_crashedTabs.js create mode 100644 browser/components/sessionstore/test/browser_docshell_uuid_consistency.js create mode 100644 browser/components/sessionstore/test/browser_duplicate_history.js create mode 100644 browser/components/sessionstore/test/browser_duplicate_tab_in_new_window.js create mode 100644 browser/components/sessionstore/test/browser_dying_cache.js create mode 100644 browser/components/sessionstore/test/browser_dynamic_frames.js create mode 100644 browser/components/sessionstore/test/browser_firefoxView_restore.js create mode 100644 browser/components/sessionstore/test/browser_firefoxView_selected_restore.js create mode 100644 browser/components/sessionstore/test/browser_focus_after_restore.js create mode 100644 browser/components/sessionstore/test/browser_forget_async_closings.js create mode 100644 browser/components/sessionstore/test/browser_formdata.js create mode 100644 browser/components/sessionstore/test/browser_formdata_cc.js create mode 100644 browser/components/sessionstore/test/browser_formdata_format.js create mode 100644 browser/components/sessionstore/test/browser_formdata_format_sample.html create mode 100644 browser/components/sessionstore/test/browser_formdata_max_size.js create mode 100644 browser/components/sessionstore/test/browser_formdata_password.js create mode 100644 browser/components/sessionstore/test/browser_formdata_sample.html create mode 100644 browser/components/sessionstore/test/browser_formdata_xpath.js create mode 100644 browser/components/sessionstore/test/browser_formdata_xpath_sample.html create mode 100644 browser/components/sessionstore/test/browser_frame_history.js create mode 100644 browser/components/sessionstore/test/browser_frame_history_a.html create mode 100644 browser/components/sessionstore/test/browser_frame_history_b.html create mode 100644 browser/components/sessionstore/test/browser_frame_history_c.html create mode 100644 browser/components/sessionstore/test/browser_frame_history_c1.html create mode 100644 browser/components/sessionstore/test/browser_frame_history_c2.html create mode 100644 browser/components/sessionstore/test/browser_frame_history_index.html create mode 100644 browser/components/sessionstore/test/browser_frame_history_index2.html create mode 100644 browser/components/sessionstore/test/browser_frame_history_index_blank.html create mode 100644 browser/components/sessionstore/test/browser_frametree.js create mode 100644 browser/components/sessionstore/test/browser_frametree_sample.html create mode 100644 browser/components/sessionstore/test/browser_frametree_sample_frameset.html create mode 100644 browser/components/sessionstore/test/browser_frametree_sample_iframes.html create mode 100644 browser/components/sessionstore/test/browser_global_store.js create mode 100644 browser/components/sessionstore/test/browser_history_persist.js create mode 100644 browser/components/sessionstore/test/browser_ignore_updates_crashed_tabs.js create mode 100644 browser/components/sessionstore/test/browser_label_and_icon.js create mode 100644 browser/components/sessionstore/test/browser_merge_closed_tabs.js create mode 100644 browser/components/sessionstore/test/browser_movePendingTabToNewWindow.js create mode 100644 browser/components/sessionstore/test/browser_multiple_navigateAndRestore.js create mode 100644 browser/components/sessionstore/test/browser_multiple_select_after_load.js create mode 100644 browser/components/sessionstore/test/browser_newtab_userTypedValue.js create mode 100644 browser/components/sessionstore/test/browser_not_collect_when_idle.js create mode 100644 browser/components/sessionstore/test/browser_old_favicon.js create mode 100644 browser/components/sessionstore/test/browser_page_title.js create mode 100644 browser/components/sessionstore/test/browser_parentProcessRestoreHash.js create mode 100644 browser/components/sessionstore/test/browser_pending_tabs.js create mode 100644 browser/components/sessionstore/test/browser_pinned_tabs.js create mode 100644 browser/components/sessionstore/test/browser_privatetabs.js create mode 100644 browser/components/sessionstore/test/browser_purge_shistory.js create mode 100644 browser/components/sessionstore/test/browser_remoteness_flip_on_restore.js create mode 100644 browser/components/sessionstore/test/browser_reopen_all_windows.js create mode 100644 browser/components/sessionstore/test/browser_replace_load.js create mode 100644 browser/components/sessionstore/test/browser_restoreTabContainer.js create mode 100644 browser/components/sessionstore/test/browser_restore_container_tabs_oa.js create mode 100644 browser/components/sessionstore/test/browser_restore_cookies_noOriginAttributes.js create mode 100644 browser/components/sessionstore/test/browser_restore_pageProxyState.js create mode 100644 browser/components/sessionstore/test/browser_restore_private_tab_os.js create mode 100644 browser/components/sessionstore/test/browser_restore_redirect.js create mode 100644 browser/components/sessionstore/test/browser_restore_reversed_z_order.js create mode 100644 browser/components/sessionstore/test/browser_restore_session_in_undoCloseTab.js create mode 100644 browser/components/sessionstore/test/browser_restore_srcdoc.js create mode 100644 browser/components/sessionstore/test/browser_restore_tabless_window.js create mode 100644 browser/components/sessionstore/test/browser_restored_window_features.js create mode 100644 browser/components/sessionstore/test/browser_revive_crashed_bg_tabs.js create mode 100644 browser/components/sessionstore/test/browser_scrollPositions.js create mode 100644 browser/components/sessionstore/test/browser_scrollPositionsReaderMode.js create mode 100644 browser/components/sessionstore/test/browser_scrollPositions_readerModeArticle.html create mode 100644 browser/components/sessionstore/test/browser_scrollPositions_sample.html create mode 100644 browser/components/sessionstore/test/browser_scrollPositions_sample2.html create mode 100644 browser/components/sessionstore/test/browser_scrollPositions_sample_frameset.html create mode 100644 browser/components/sessionstore/test/browser_send_async_message_oom.js create mode 100644 browser/components/sessionstore/test/browser_sessionHistory.js create mode 100644 browser/components/sessionstore/test/browser_sessionHistory_slow.sjs create mode 100644 browser/components/sessionstore/test/browser_sessionStorage.html create mode 100644 browser/components/sessionstore/test/browser_sessionStorage.js create mode 100644 browser/components/sessionstore/test/browser_sessionStorage_size.js create mode 100644 browser/components/sessionstore/test/browser_sessionStoreContainer.js create mode 100644 browser/components/sessionstore/test/browser_sizemodeBeforeMinimized.js create mode 100644 browser/components/sessionstore/test/browser_speculative_connect.html create mode 100644 browser/components/sessionstore/test/browser_speculative_connect.js create mode 100644 browser/components/sessionstore/test/browser_swapDocShells.js create mode 100644 browser/components/sessionstore/test/browser_switch_remoteness.js create mode 100644 browser/components/sessionstore/test/browser_tab_label_during_restore.js create mode 100644 browser/components/sessionstore/test/browser_tabicon_after_bg_tab_crash.js create mode 100644 browser/components/sessionstore/test/browser_tabs_in_urlbar.js create mode 100644 browser/components/sessionstore/test/browser_undoCloseById.js create mode 100644 browser/components/sessionstore/test/browser_unrestored_crashedTabs.js create mode 100644 browser/components/sessionstore/test/browser_upgrade_backup.js create mode 100644 browser/components/sessionstore/test/browser_urlbarSearchMode.js create mode 100644 browser/components/sessionstore/test/browser_userTyped_restored_after_discard.js create mode 100644 browser/components/sessionstore/test/browser_windowRestore_perwindowpb.js create mode 100644 browser/components/sessionstore/test/browser_windowStateContainer.js create mode 100644 browser/components/sessionstore/test/coopHeaderCommon.sjs create mode 100644 browser/components/sessionstore/test/coop_coep.html create mode 100644 browser/components/sessionstore/test/coop_coep.html^headers^ create mode 100644 browser/components/sessionstore/test/empty.html create mode 100644 browser/components/sessionstore/test/file_async_duplicate_tab.html create mode 100644 browser/components/sessionstore/test/file_async_flushes.html create mode 100644 browser/components/sessionstore/test/file_formdata_password.html create mode 100644 browser/components/sessionstore/test/file_sessionHistory_hashchange.html create mode 100644 browser/components/sessionstore/test/head.js create mode 100644 browser/components/sessionstore/test/marionette/manifest.ini create mode 100644 browser/components/sessionstore/test/marionette/session_store_test_case.py create mode 100644 browser/components/sessionstore/test/marionette/test_restore_loading_tab.py create mode 100644 browser/components/sessionstore/test/marionette/test_restore_manually_with_pinned_tabs.py create mode 100644 browser/components/sessionstore/test/marionette/test_restore_windows_after_close_last_tabs.py create mode 100644 browser/components/sessionstore/test/marionette/test_restore_windows_after_restart_and_quit.py create mode 100644 browser/components/sessionstore/test/marionette/test_restore_windows_after_windows_shutdown.py create mode 100644 browser/components/sessionstore/test/restore_redirect_http.html create mode 100644 browser/components/sessionstore/test/restore_redirect_http.html^headers^ create mode 100644 browser/components/sessionstore/test/restore_redirect_js.html create mode 100644 browser/components/sessionstore/test/restore_redirect_target.html create mode 100644 browser/components/sessionstore/test/unit/data/sessionCheckpoints_all.json create mode 100644 browser/components/sessionstore/test/unit/data/sessionstore_invalid.js create mode 100644 browser/components/sessionstore/test/unit/data/sessionstore_valid.js create mode 100644 browser/components/sessionstore/test/unit/head.js create mode 100644 browser/components/sessionstore/test/unit/test_backup_once.js create mode 100644 browser/components/sessionstore/test/unit/test_final_write_cleanup.js create mode 100644 browser/components/sessionstore/test/unit/test_histogram_corrupt_files.js create mode 100644 browser/components/sessionstore/test/unit/test_migration_lz4compression.js create mode 100644 browser/components/sessionstore/test/unit/test_startup_invalid_session.js create mode 100644 browser/components/sessionstore/test/unit/test_startup_nosession_async.js create mode 100644 browser/components/sessionstore/test/unit/test_startup_session_async.js create mode 100644 browser/components/sessionstore/test/unit/xpcshell.ini (limited to 'browser/components/sessionstore') diff --git a/browser/components/sessionstore/ContentRestore.sys.mjs b/browser/components/sessionstore/ContentRestore.sys.mjs new file mode 100644 index 0000000000..e55772cab3 --- /dev/null +++ b/browser/components/sessionstore/ContentRestore.sys.mjs @@ -0,0 +1,435 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + SessionHistory: "resource://gre/modules/sessionstore/SessionHistory.sys.mjs", + Utils: "resource://gre/modules/sessionstore/Utils.sys.mjs", +}); + +/** + * This module implements the content side of session restoration. The chrome + * side is handled by SessionStore.sys.mjs. The functions in this module are called + * by content-sessionStore.js based on messages received from SessionStore.sys.mjs + * (or, in one case, based on a "load" event). Each tab has its own + * ContentRestore instance, constructed by content-sessionStore.js. + * + * In a typical restore, content-sessionStore.js will call the following based + * on messages and events it receives: + * + * restoreHistory(tabData, loadArguments, callbacks) + * Restores the tab's history and session cookies. + * restoreTabContent(loadArguments, finishCallback) + * Starts loading the data for the current page to restore. + * restoreDocument() + * Restore form and scroll data. + * + * When the page has been loaded from the network, we call finishCallback. It + * should send a message to SessionStore.sys.mjs, which may cause other tabs to be + * restored. + * + * When the page has finished loading, a "load" event will trigger in + * content-sessionStore.js, which will call restoreDocument. At that point, + * form data is restored and the restore is complete. + * + * At any time, SessionStore.sys.mjs can cancel the ongoing restore by sending a + * reset message, which causes resetRestore to be called. At that point it's + * legal to begin another restore. + */ +export function ContentRestore(chromeGlobal) { + let internal = new ContentRestoreInternal(chromeGlobal); + let external = {}; + + let EXPORTED_METHODS = [ + "restoreHistory", + "restoreTabContent", + "restoreDocument", + "resetRestore", + ]; + + for (let method of EXPORTED_METHODS) { + external[method] = internal[method].bind(internal); + } + + return Object.freeze(external); +} + +function ContentRestoreInternal(chromeGlobal) { + this.chromeGlobal = chromeGlobal; + + // The following fields are only valid during certain phases of the restore + // process. + + // The tabData for the restore. Set in restoreHistory and removed in + // restoreTabContent. + this._tabData = null; + + // Contains {entry, scrollPositions, formdata}, where entry is a + // single entry from the tabData.entries array. Set in + // restoreTabContent and removed in restoreDocument. + this._restoringDocument = null; + + // This listener is used to detect reloads on restoring tabs. Set in + // restoreHistory and removed in restoreTabContent. + this._historyListener = null; + + // This listener detects when a pending tab starts loading (when not + // initiated by sessionstore) and when a restoring tab has finished loading + // data from the network. Set in restoreHistory() and restoreTabContent(), + // removed in resetRestore(). + this._progressListener = null; +} + +/** + * The API for the ContentRestore module. Methods listed in EXPORTED_METHODS are + * public. + */ +ContentRestoreInternal.prototype = { + get docShell() { + return this.chromeGlobal.docShell; + }, + + /** + * Starts the process of restoring a tab. The tabData to be restored is passed + * in here and used throughout the restoration. The epoch (which must be + * non-zero) is passed through to all the callbacks. If a load in the tab + * is started while it is pending, the appropriate callbacks are called. + */ + restoreHistory(tabData, loadArguments, callbacks) { + this._tabData = tabData; + + // In case about:blank isn't done yet. + let webNavigation = this.docShell.QueryInterface(Ci.nsIWebNavigation); + webNavigation.stop(Ci.nsIWebNavigation.STOP_ALL); + + // Make sure currentURI is set so that switch-to-tab works before the tab is + // restored. We'll reset this to about:blank when we try to restore the tab + // to ensure that docshell doeesn't get confused. Don't bother doing this if + // we're restoring immediately due to a process switch. It just causes the + // URL bar to be temporarily blank. + let activeIndex = tabData.index - 1; + let activePageData = tabData.entries[activeIndex] || {}; + let uri = activePageData.url || null; + if (uri && !loadArguments) { + webNavigation.setCurrentURIForSessionStore(Services.io.newURI(uri)); + } + + lazy.SessionHistory.restore(this.docShell, tabData); + + // Add a listener to watch for reloads. + let listener = new HistoryListener(this.docShell, () => { + // On reload, restore tab contents. + this.restoreTabContent(null, false, callbacks.onLoadFinished); + }); + + webNavigation.sessionHistory.legacySHistory.addSHistoryListener(listener); + this._historyListener = listener; + + // Make sure to reset the capabilities and attributes in case this tab gets + // reused. + SessionStoreUtils.restoreDocShellCapabilities( + this.docShell, + tabData.disallow + ); + + // Add a progress listener to correctly handle browser.loadURI() + // calls from foreign code. + this._progressListener = new ProgressListener(this.docShell, { + onStartRequest: () => { + // Some code called browser.loadURI() on a pending tab. It's safe to + // assume we don't care about restoring scroll or form data. + this._tabData = null; + + // Listen for the tab to finish loading. + this.restoreTabContentStarted(callbacks.onLoadFinished); + + // Notify the parent. + callbacks.onLoadStarted(); + }, + }); + }, + + /** + * Start loading the current page. When the data has finished loading from the + * network, finishCallback is called. Returns true if the load was successful. + */ + restoreTabContent(loadArguments, isRemotenessUpdate, finishCallback) { + let tabData = this._tabData; + this._tabData = null; + + let webNavigation = this.docShell.QueryInterface(Ci.nsIWebNavigation); + + // Listen for the tab to finish loading. + this.restoreTabContentStarted(finishCallback); + + // Reset the current URI to about:blank. We changed it above for + // switch-to-tab, but now it must go back to the correct value before the + // load happens. Don't bother doing this if we're restoring immediately + // due to a process switch. + if (!isRemotenessUpdate) { + webNavigation.setCurrentURIForSessionStore( + Services.io.newURI("about:blank") + ); + } + + try { + if (loadArguments) { + // If the load was started in another process, and the in-flight channel + // was redirected into this process, resume that load within our process. + // + // NOTE: In this case `isRemotenessUpdate` must be true. + webNavigation.resumeRedirectedLoad( + loadArguments.redirectLoadSwitchId, + loadArguments.redirectHistoryIndex + ); + } else if (tabData.userTypedValue && tabData.userTypedClear) { + // If the user typed a URL into the URL bar and hit enter right before + // we crashed, we want to start loading that page again. A non-zero + // userTypedClear value means that the load had started. + // Load userTypedValue and fix up the URL if it's partial/broken. + let loadURIOptions = { + triggeringPrincipal: + Services.scriptSecurityManager.getSystemPrincipal(), + loadFlags: Ci.nsIWebNavigation.LOAD_FLAGS_ALLOW_THIRD_PARTY_FIXUP, + }; + webNavigation.fixupAndLoadURIString( + tabData.userTypedValue, + loadURIOptions + ); + } else if (tabData.entries.length) { + // Stash away the data we need for restoreDocument. + this._restoringDocument = { + formdata: tabData.formdata || {}, + scrollPositions: tabData.scroll || {}, + }; + + // In order to work around certain issues in session history, we need to + // force session history to update its internal index and call reload + // instead of gotoIndex. See bug 597315. + let history = webNavigation.sessionHistory.legacySHistory; + history.reloadCurrentEntry(); + } else { + // If there's nothing to restore, we should still blank the page. + let loadURIOptions = { + triggeringPrincipal: + Services.scriptSecurityManager.getSystemPrincipal(), + loadFlags: Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_HISTORY, + // Specify an override to force the load to finish in the current + // process, as tests rely on this behaviour for non-fission session + // restore. + remoteTypeOverride: Services.appinfo.remoteType, + }; + webNavigation.loadURI( + Services.io.newURI("about:blank"), + loadURIOptions + ); + } + + return true; + } catch (ex) { + if (ex instanceof Ci.nsIException) { + // Ignore page load errors, but return false to signal that the load never + // happened. + return false; + } + } + return null; + }, + + /** + * To be called after restoreHistory(). Removes all listeners needed for + * pending tabs and makes sure to notify when the tab finished loading. + */ + restoreTabContentStarted(finishCallback) { + // The reload listener is no longer needed. + this._historyListener.uninstall(); + this._historyListener = null; + + // Remove the old progress listener. + this._progressListener.uninstall(); + + // We're about to start a load. This listener will be called when the load + // has finished getting everything from the network. + this._progressListener = new ProgressListener(this.docShell, { + onStopRequest: () => { + // Call resetRestore() to reset the state back to normal. The data + // needed for restoreDocument() (which hasn't happened yet) will + // remain in _restoringDocument. + this.resetRestore(); + + finishCallback(); + }, + }); + }, + + /** + * Finish restoring the tab by filling in form data and setting the scroll + * position. The restore is complete when this function exits. It should be + * called when the "load" event fires for the restoring tab. Returns true + * if we're restoring a document. + */ + restoreDocument() { + if (!this._restoringDocument) { + return; + } + + let { formdata, scrollPositions } = this._restoringDocument; + this._restoringDocument = null; + + let window = this.docShell.domWindow; + + // Restore form data. + lazy.Utils.restoreFrameTreeData(window, formdata, (frame, data) => { + // restore() will return false, and thus abort restoration for the + // current |frame| and its descendants, if |data.url| is given but + // doesn't match the loaded document's URL. + return SessionStoreUtils.restoreFormData(frame.document, data); + }); + + // Restore scroll data. + lazy.Utils.restoreFrameTreeData(window, scrollPositions, (frame, data) => { + if (data.scroll) { + SessionStoreUtils.restoreScrollPosition(frame, data); + } + }); + }, + + /** + * Cancel an ongoing restore. This function can be called any time between + * restoreHistory and restoreDocument. + * + * This function is called externally (if a restore is canceled) and + * internally (when the loads for a restore have finished). In the latter + * case, it's called before restoreDocument, so it cannot clear + * _restoringDocument. + */ + resetRestore() { + this._tabData = null; + + if (this._historyListener) { + this._historyListener.uninstall(); + } + this._historyListener = null; + + if (this._progressListener) { + this._progressListener.uninstall(); + } + this._progressListener = null; + }, +}; + +/* + * This listener detects when a page being restored is reloaded. It triggers a + * callback and cancels the reload. The callback will send a message to + * SessionStore.sys.mjs so that it can restore the content immediately. + */ +function HistoryListener(docShell, callback) { + let webNavigation = docShell.QueryInterface(Ci.nsIWebNavigation); + webNavigation.sessionHistory.legacySHistory.addSHistoryListener(this); + + this.webNavigation = webNavigation; + this.callback = callback; +} +HistoryListener.prototype = { + QueryInterface: ChromeUtils.generateQI([ + "nsISHistoryListener", + "nsISupportsWeakReference", + ]), + + uninstall() { + let shistory = this.webNavigation.sessionHistory.legacySHistory; + if (shistory) { + shistory.removeSHistoryListener(this); + } + }, + + OnHistoryGotoIndex() {}, + OnHistoryPurge() {}, + OnHistoryReplaceEntry() {}, + + // This will be called for a pending tab when loadURI(uri) is called where + // the given |uri| only differs in the fragment. + OnHistoryNewEntry(newURI) { + let currentURI = this.webNavigation.currentURI; + + // Ignore new SHistory entries with the same URI as those do not indicate + // a navigation inside a document by changing the #hash part of the URL. + // We usually hit this when purging session history for browsers. + if (currentURI && currentURI.spec == newURI.spec) { + return; + } + + // Reset the tab's URL to what it's actually showing. Without this loadURI() + // would use the current document and change the displayed URL only. + this.webNavigation.setCurrentURIForSessionStore( + Services.io.newURI("about:blank") + ); + + // Kick off a new load so that we navigate away from about:blank to the + // new URL that was passed to loadURI(). The new load will cause a + // STATE_START notification to be sent and the ProgressListener will then + // notify the parent and do the rest. + let loadFlags = Ci.nsIWebNavigation.LOAD_FLAGS_ALLOW_THIRD_PARTY_FIXUP; + let loadURIOptions = { + triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(), + loadFlags, + }; + this.webNavigation.loadURI(newURI, loadURIOptions); + }, + + OnHistoryReload() { + this.callback(); + + // Cancel the load. + return false; + }, +}; + +/** + * This class informs SessionStore.sys.mjs whenever the network requests for a + * restoring page have completely finished. We only restore three tabs + * simultaneously, so this is the signal for SessionStore.sys.mjs to kick off + * another restore (if there are more to do). + * + * The progress listener is also used to be notified when a load not initiated + * by sessionstore starts. Pending tabs will then need to be marked as no + * longer pending. + */ +function ProgressListener(docShell, callbacks) { + let webProgress = docShell + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebProgress); + webProgress.addProgressListener(this, Ci.nsIWebProgress.NOTIFY_STATE_WINDOW); + + this.webProgress = webProgress; + this.callbacks = callbacks; +} + +ProgressListener.prototype = { + QueryInterface: ChromeUtils.generateQI([ + "nsIWebProgressListener", + "nsISupportsWeakReference", + ]), + + uninstall() { + this.webProgress.removeProgressListener(this); + }, + + onStateChange(webProgress, request, stateFlags, status) { + let { STATE_IS_WINDOW, STATE_STOP, STATE_START } = + Ci.nsIWebProgressListener; + if (!webProgress.isTopLevel || !(stateFlags & STATE_IS_WINDOW)) { + return; + } + + if (stateFlags & STATE_START && this.callbacks.onStartRequest) { + this.callbacks.onStartRequest(); + } + + if (stateFlags & STATE_STOP && this.callbacks.onStopRequest) { + this.callbacks.onStopRequest(); + } + }, +}; diff --git a/browser/components/sessionstore/ContentSessionStore.sys.mjs b/browser/components/sessionstore/ContentSessionStore.sys.mjs new file mode 100644 index 0000000000..61aa911f0b --- /dev/null +++ b/browser/components/sessionstore/ContentSessionStore.sys.mjs @@ -0,0 +1,686 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 { + clearTimeout, + setTimeoutWithTarget, +} from "resource://gre/modules/Timer.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ContentRestore: "resource:///modules/sessionstore/ContentRestore.sys.mjs", + SessionHistory: "resource://gre/modules/sessionstore/SessionHistory.sys.mjs", +}); + +// This pref controls whether or not we send updates to the parent on a timeout +// or not, and should only be used for tests or debugging. +const TIMEOUT_DISABLED_PREF = "browser.sessionstore.debug.no_auto_updates"; + +const PREF_INTERVAL = "browser.sessionstore.interval"; + +const kNoIndex = Number.MAX_SAFE_INTEGER; +const kLastIndex = Number.MAX_SAFE_INTEGER - 1; + +class Handler { + constructor(store) { + this.store = store; + } + + get contentRestore() { + return this.store.contentRestore; + } + + get contentRestoreInitialized() { + return this.store.contentRestoreInitialized; + } + + get mm() { + return this.store.mm; + } + + get messageQueue() { + return this.store.messageQueue; + } +} + +/** + * Listens for and handles content events that we need for the + * session store service to be notified of state changes in content. + */ +class EventListener extends Handler { + constructor(store) { + super(store); + + SessionStoreUtils.addDynamicFrameFilteredListener( + this.mm, + "load", + this, + true + ); + } + + handleEvent(event) { + let { content } = this.mm; + + // Ignore load events from subframes. + if (event.target != content.document) { + return; + } + + if (content.document.documentURI.startsWith("about:reader")) { + if ( + event.type == "load" && + !content.document.body.classList.contains("loaded") + ) { + // Don't restore the scroll position of an about:reader page at this + // point; listen for the custom event dispatched from AboutReader.sys.mjs. + content.addEventListener("AboutReaderContentReady", this); + return; + } + + content.removeEventListener("AboutReaderContentReady", this); + } + + if (this.contentRestoreInitialized) { + // Restore the form data and scroll position. + this.contentRestore.restoreDocument(); + } + } +} + +/** + * Listens for changes to the session history. Whenever the user navigates + * we will collect URLs and everything belonging to session history. + * + * Causes a SessionStore:update message to be sent that contains the current + * session history. + * + * Example: + * {entries: [{url: "about:mozilla", ...}, ...], index: 1} + */ +class SessionHistoryListener extends Handler { + constructor(store) { + super(store); + + this._fromIdx = kNoIndex; + + // By adding the SHistoryListener immediately, we will unfortunately be + // notified of every history entry as the tab is restored. We don't bother + // waiting to add the listener later because these notifications are cheap. + // We will likely only collect once since we are batching collection on + // a delay. + this.mm.docShell + .QueryInterface(Ci.nsIWebNavigation) + .sessionHistory.legacySHistory.addSHistoryListener(this); // OK in non-geckoview + + let webProgress = this.mm.docShell + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebProgress); + + webProgress.addProgressListener( + this, + Ci.nsIWebProgress.NOTIFY_STATE_DOCUMENT + ); + + // Collect data if we start with a non-empty shistory. + if (!lazy.SessionHistory.isEmpty(this.mm.docShell)) { + this.collect(); + // When a tab is detached from the window, for the new window there is a + // new SessionHistoryListener created. Normally it is empty at this point + // but in a test env. the initial about:blank might have a children in which + // case we fire off a history message here with about:blank in it. If we + // don't do it ASAP then there is going to be a browser swap and the parent + // will be all confused by that message. + this.store.messageQueue.send(); + } + + // Listen for page title changes. + this.mm.addEventListener("DOMTitleChanged", this); + } + + get mm() { + return this.store.mm; + } + + uninit() { + let sessionHistory = this.mm.docShell.QueryInterface( + Ci.nsIWebNavigation + ).sessionHistory; + if (sessionHistory) { + sessionHistory.legacySHistory.removeSHistoryListener(this); // OK in non-geckoview + } + } + + collect() { + // We want to send down a historychange even for full collects in case our + // session history is a partial session history, in which case we don't have + // enough information for a full update. collectFrom(-1) tells the collect + // function to collect all data avaliable in this process. + if (this.mm.docShell) { + this.collectFrom(-1); + } + } + + // History can grow relatively big with the nested elements, so if we don't have to, we + // don't want to send the entire history all the time. For a simple optimization + // we keep track of the smallest index from after any change has occured and we just send + // the elements from that index. If something more complicated happens we just clear it + // and send the entire history. We always send the additional info like the current selected + // index (so for going back and forth between history entries we set the index to kLastIndex + // if nothing else changed send an empty array and the additonal info like the selected index) + collectFrom(idx) { + if (this._fromIdx <= idx) { + // If we already know that we need to update history fromn index N we can ignore any changes + // tha happened with an element with index larger than N. + // Note: initially we use kNoIndex which is MAX_SAFE_INTEGER which means we don't ignore anything + // here, and in case of navigation in the history back and forth we use kLastIndex which ignores + // only the subsequent navigations, but not any new elements added. + return; + } + + this._fromIdx = idx; + this.store.messageQueue.push("historychange", () => { + if (this._fromIdx === kNoIndex) { + return null; + } + + let history = lazy.SessionHistory.collect( + this.mm.docShell, + this._fromIdx + ); + this._fromIdx = kNoIndex; + return history; + }); + } + + handleEvent(event) { + this.collect(); + } + + OnHistoryNewEntry(newURI, oldIndex) { + // Collect the current entry as well, to make sure to collect any changes + // that were made to the entry while the document was active. + this.collectFrom(oldIndex == -1 ? oldIndex : oldIndex - 1); + } + + OnHistoryGotoIndex() { + // We ought to collect the previously current entry as well, see bug 1350567. + this.collectFrom(kLastIndex); + } + + OnHistoryPurge() { + this.collect(); + } + + OnHistoryReload() { + this.collect(); + return true; + } + + OnHistoryReplaceEntry() { + this.collect(); + } + + /** + * @see nsIWebProgressListener.onStateChange + */ + onStateChange(webProgress, request, stateFlags, status) { + // Ignore state changes for subframes because we're only interested in the + // top-document starting or stopping its load. + if (!webProgress.isTopLevel || webProgress.DOMWindow != this.mm.content) { + return; + } + + // onStateChange will be fired when loading the initial about:blank URI for + // a browser, which we don't actually care about. This is particularly for + // the case of unrestored background tabs, where the content has not yet + // been restored: we don't want to accidentally send any updates to the + // parent when the about:blank placeholder page has loaded. + if (!this.mm.docShell.hasLoadedNonBlankURI) { + return; + } + + if (stateFlags & Ci.nsIWebProgressListener.STATE_START) { + this.collect(); + } else if (stateFlags & Ci.nsIWebProgressListener.STATE_STOP) { + this.collect(); + } + } +} +SessionHistoryListener.prototype.QueryInterface = ChromeUtils.generateQI([ + "nsIWebProgressListener", + "nsISHistoryListener", + "nsISupportsWeakReference", +]); + +/** + * A message queue that takes collected data and will take care of sending it + * to the chrome process. It allows flushing using synchronous messages and + * takes care of any race conditions that might occur because of that. Changes + * will be batched if they're pushed in quick succession to avoid a message + * flood. + */ +class MessageQueue extends Handler { + constructor(store) { + super(store); + + /** + * A map (string -> lazy fn) holding lazy closures of all queued data + * collection routines. These functions will return data collected from the + * docShell. + */ + this._data = new Map(); + + /** + * The delay (in ms) used to delay sending changes after data has been + * invalidated. + */ + this.BATCH_DELAY_MS = 1000; + + /** + * The minimum idle period (in ms) we need for sending data to chrome process. + */ + this.NEEDED_IDLE_PERIOD_MS = 5; + + /** + * Timeout for waiting an idle period to send data. We will set this from + * the pref "browser.sessionstore.interval". + */ + this._timeoutWaitIdlePeriodMs = null; + + /** + * The current timeout ID, null if there is no queue data. We use timeouts + * to damp a flood of data changes and send lots of changes as one batch. + */ + this._timeout = null; + + /** + * Whether or not sending batched messages on a timer is disabled. This should + * only be used for debugging or testing. If you need to access this value, + * you should probably use the timeoutDisabled getter. + */ + this._timeoutDisabled = false; + + /** + * True if there is already a send pending idle dispatch, set to prevent + * scheduling more than one. If false there may or may not be one scheduled. + */ + this._idleScheduled = false; + + this.timeoutDisabled = Services.prefs.getBoolPref(TIMEOUT_DISABLED_PREF); + this._timeoutWaitIdlePeriodMs = Services.prefs.getIntPref(PREF_INTERVAL); + + Services.prefs.addObserver(TIMEOUT_DISABLED_PREF, this); + Services.prefs.addObserver(PREF_INTERVAL, this); + } + + /** + * True if batched messages are not being fired on a timer. This should only + * ever be true when debugging or during tests. + */ + get timeoutDisabled() { + return this._timeoutDisabled; + } + + /** + * Disables sending batched messages on a timer. Also cancels any pending + * timers. + */ + set timeoutDisabled(val) { + this._timeoutDisabled = val; + + if (val && this._timeout) { + clearTimeout(this._timeout); + this._timeout = null; + } + } + + uninit() { + Services.prefs.removeObserver(TIMEOUT_DISABLED_PREF, this); + Services.prefs.removeObserver(PREF_INTERVAL, this); + this.cleanupTimers(); + } + + /** + * Cleanup pending idle callback and timer. + */ + cleanupTimers() { + this._idleScheduled = false; + if (this._timeout) { + clearTimeout(this._timeout); + this._timeout = null; + } + } + + observe(subject, topic, data) { + if (topic == "nsPref:changed") { + switch (data) { + case TIMEOUT_DISABLED_PREF: + this.timeoutDisabled = Services.prefs.getBoolPref( + TIMEOUT_DISABLED_PREF + ); + break; + case PREF_INTERVAL: + this._timeoutWaitIdlePeriodMs = + Services.prefs.getIntPref(PREF_INTERVAL); + break; + default: + console.error("received unknown message '" + data + "'"); + break; + } + } + } + + /** + * Pushes a given |value| onto the queue. The given |key| represents the type + * of data that is stored and can override data that has been queued before + * but has not been sent to the parent process, yet. + * + * @param key (string) + * A unique identifier specific to the type of data this is passed. + * @param fn (function) + * A function that returns the value that will be sent to the parent + * process. + */ + push(key, fn) { + this._data.set(key, fn); + + if (!this._timeout && !this._timeoutDisabled) { + // Wait a little before sending the message to batch multiple changes. + this._timeout = setTimeoutWithTarget( + () => this.sendWhenIdle(), + this.BATCH_DELAY_MS, + this.mm.tabEventTarget + ); + } + } + + /** + * Sends queued data when the remaining idle time is enough or waiting too + * long; otherwise, request an idle time again. If the |deadline| is not + * given, this function is going to schedule the first request. + * + * @param deadline (object) + * An IdleDeadline object passed by idleDispatch(). + */ + sendWhenIdle(deadline) { + if (!this.mm.content) { + // The frameloader is being torn down. Nothing more to do. + return; + } + + if (deadline) { + if ( + deadline.didTimeout || + deadline.timeRemaining() > this.NEEDED_IDLE_PERIOD_MS + ) { + this.send(); + return; + } + } else if (this._idleScheduled) { + // Bail out if there's a pending run. + return; + } + ChromeUtils.idleDispatch(deadline_ => this.sendWhenIdle(deadline_), { + timeout: this._timeoutWaitIdlePeriodMs, + }); + this._idleScheduled = true; + } + + /** + * Sends queued data to the chrome process. + * + * @param options (object) + * {flushID: 123} to specify that this is a flush + * {isFinal: true} to signal this is the final message sent on unload + */ + send(options = {}) { + // Looks like we have been called off a timeout after the tab has been + // closed. The docShell is gone now and we can just return here as there + // is nothing to do. + if (!this.mm.docShell) { + return; + } + + this.cleanupTimers(); + + let flushID = (options && options.flushID) || 0; + let histID = "FX_SESSION_RESTORE_CONTENT_COLLECT_DATA_MS"; + + let data = {}; + for (let [key, func] of this._data) { + if (key != "isPrivate") { + TelemetryStopwatch.startKeyed(histID, key); + } + + let value = func(); + + if (key != "isPrivate") { + TelemetryStopwatch.finishKeyed(histID, key); + } + + if (value || (key != "storagechange" && key != "historychange")) { + data[key] = value; + } + } + + this._data.clear(); + + try { + // Send all data to the parent process. + this.mm.sendAsyncMessage("SessionStore:update", { + data, + flushID, + isFinal: options.isFinal || false, + epoch: this.store.epoch, + }); + } catch (ex) { + if (ex && ex.result == Cr.NS_ERROR_OUT_OF_MEMORY) { + Services.telemetry + .getHistogramById("FX_SESSION_RESTORE_SEND_UPDATE_CAUSED_OOM") + .add(1); + this.mm.sendAsyncMessage("SessionStore:error"); + } + } + } +} + +/** + * Listens for and handles messages sent by the session store service. + */ +const MESSAGES = [ + "SessionStore:restoreHistory", + "SessionStore:restoreTabContent", + "SessionStore:resetRestore", + "SessionStore:flush", + "SessionStore:prepareForProcessChange", +]; + +export class ContentSessionStore { + constructor(mm) { + if (Services.appinfo.sessionHistoryInParent) { + throw new Error("This frame script should not be loaded for SHIP"); + } + + this.mm = mm; + this.messageQueue = new MessageQueue(this); + + this.epoch = 0; + + this.contentRestoreInitialized = false; + + this.handlers = [ + this.messageQueue, + new EventListener(this), + new SessionHistoryListener(this), + ]; + + XPCOMUtils.defineLazyGetter(this, "contentRestore", () => { + this.contentRestoreInitialized = true; + return new lazy.ContentRestore(mm); + }); + + MESSAGES.forEach(m => mm.addMessageListener(m, this)); + + mm.addEventListener("unload", this); + } + + receiveMessage({ name, data }) { + // The docShell might be gone. Don't process messages, + // that will just lead to errors anyway. + if (!this.mm.docShell) { + return; + } + + // A fresh tab always starts with epoch=0. The parent has the ability to + // override that to signal a new era in this tab's life. This enables it + // to ignore async messages that were already sent but not yet received + // and would otherwise confuse the internal tab state. + if (data && data.epoch && data.epoch != this.epoch) { + this.epoch = data.epoch; + } + + switch (name) { + case "SessionStore:restoreHistory": + this.restoreHistory(data); + break; + case "SessionStore:restoreTabContent": + this.restoreTabContent(data); + break; + case "SessionStore:resetRestore": + this.contentRestore.resetRestore(); + break; + case "SessionStore:flush": + this.flush(data); + break; + case "SessionStore:prepareForProcessChange": + // During normal in-process navigations, the DocShell would take + // care of automatically persisting layout history state to record + // scroll positions on the nsSHEntry. Unfortunately, process switching + // is not a normal navigation, so for now we do this ourselves. This + // is a workaround until session history state finally lives in the + // parent process. + this.mm.docShell.persistLayoutHistoryState(); + break; + default: + console.error("received unknown message '" + name + "'"); + break; + } + } + + // non-SHIP only + restoreHistory(data) { + let { epoch, tabData, loadArguments, isRemotenessUpdate } = data; + + this.contentRestore.restoreHistory(tabData, loadArguments, { + // Note: The callbacks passed here will only be used when a load starts + // that was not initiated by sessionstore itself. This can happen when + // some code calls browser.loadURI() or browser.reload() on a pending + // browser/tab. + + onLoadStarted: () => { + // Notify the parent that the tab is no longer pending. + this.mm.sendAsyncMessage("SessionStore:restoreTabContentStarted", { + epoch, + }); + }, + + onLoadFinished: () => { + // Tell SessionStore.sys.mjs that it may want to restore some more tabs, + // since it restores a max of MAX_CONCURRENT_TAB_RESTORES at a time. + this.mm.sendAsyncMessage("SessionStore:restoreTabContentComplete", { + epoch, + }); + }, + }); + + if (Services.appinfo.processType == Services.appinfo.PROCESS_TYPE_DEFAULT) { + // For non-remote tabs, when restoreHistory finishes, we send a synchronous + // message to SessionStore.sys.mjs so that it can run SSTabRestoring. Users of + // SSTabRestoring seem to get confused if chrome and content are out of + // sync about the state of the restore (particularly regarding + // docShell.currentURI). Using a synchronous message is the easiest way + // to temporarily synchronize them. + // + // For remote tabs, because all nsIWebProgress notifications are sent + // asynchronously using messages, we get the same-order guarantees of the + // message manager, and can use an async message. + this.mm.sendSyncMessage("SessionStore:restoreHistoryComplete", { + epoch, + isRemotenessUpdate, + }); + } else { + this.mm.sendAsyncMessage("SessionStore:restoreHistoryComplete", { + epoch, + isRemotenessUpdate, + }); + } + } + + restoreTabContent({ loadArguments, isRemotenessUpdate, reason }) { + let epoch = this.epoch; + + // We need to pass the value of didStartLoad back to SessionStore.sys.mjs. + let didStartLoad = this.contentRestore.restoreTabContent( + loadArguments, + isRemotenessUpdate, + () => { + // Tell SessionStore.sys.mjs that it may want to restore some more tabs, + // since it restores a max of MAX_CONCURRENT_TAB_RESTORES at a time. + this.mm.sendAsyncMessage("SessionStore:restoreTabContentComplete", { + epoch, + isRemotenessUpdate, + }); + } + ); + + this.mm.sendAsyncMessage("SessionStore:restoreTabContentStarted", { + epoch, + isRemotenessUpdate, + reason, + }); + + if (!didStartLoad) { + // Pretend that the load succeeded so that event handlers fire correctly. + this.mm.sendAsyncMessage("SessionStore:restoreTabContentComplete", { + epoch, + isRemotenessUpdate, + }); + } + } + + flush({ id }) { + // Flush the message queue, send the latest updates. + this.messageQueue.send({ flushID: id }); + } + + handleEvent(event) { + if (event.type == "unload") { + this.onUnload(); + } + } + + onUnload() { + // Upon frameLoader destruction, send a final update message to + // the parent and flush all data currently held in the child. + this.messageQueue.send({ isFinal: true }); + + for (let handler of this.handlers) { + if (handler.uninit) { + handler.uninit(); + } + } + + if (this.contentRestoreInitialized) { + // Remove progress listeners. + this.contentRestore.resetRestore(); + } + + // We don't need to take care of any StateChangeNotifier observers as they + // will die with the content script. The same goes for the privacy transition + // observer that will die with the docShell when the tab is closed. + } +} diff --git a/browser/components/sessionstore/GlobalState.sys.mjs b/browser/components/sessionstore/GlobalState.sys.mjs new file mode 100644 index 0000000000..a49fe4650d --- /dev/null +++ b/browser/components/sessionstore/GlobalState.sys.mjs @@ -0,0 +1,88 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 EXPORTED_METHODS = [ + "getState", + "clear", + "get", + "set", + "delete", + "setFromState", +]; + +/** + * Module that contains global session data. + */ +export function GlobalState() { + let internal = new GlobalStateInternal(); + let external = {}; + for (let method of EXPORTED_METHODS) { + external[method] = internal[method].bind(internal); + } + return Object.freeze(external); +} + +function GlobalStateInternal() { + // Storage for global state. + this.state = {}; +} + +GlobalStateInternal.prototype = { + /** + * Get all value from the global state. + */ + getState() { + return this.state; + }, + + /** + * Clear all currently stored global state. + */ + clear() { + this.state = {}; + }, + + /** + * Retrieve a value from the global state. + * + * @param aKey + * A key the value is stored under. + * @return The value stored at aKey, or an empty string if no value is set. + */ + get(aKey) { + return this.state[aKey] || ""; + }, + + /** + * Set a global value. + * + * @param aKey + * A key to store the value under. + */ + set(aKey, aStringValue) { + this.state[aKey] = aStringValue; + }, + + /** + * Delete a global value. + * + * @param aKey + * A key to delete the value for. + */ + delete(aKey) { + delete this.state[aKey]; + }, + + /** + * Set the current global state from a state object. Any previous global + * state will be removed, even if the new state does not contain a matching + * key. + * + * @param aState + * A state object to extract global state from to be set. + */ + setFromState(aState) { + this.state = (aState && aState.global) || {}; + }, +}; diff --git a/browser/components/sessionstore/RecentlyClosedTabsAndWindowsMenuUtils.sys.mjs b/browser/components/sessionstore/RecentlyClosedTabsAndWindowsMenuUtils.sys.mjs new file mode 100644 index 0000000000..10cc02e35c --- /dev/null +++ b/browser/components/sessionstore/RecentlyClosedTabsAndWindowsMenuUtils.sys.mjs @@ -0,0 +1,254 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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, { + PlacesUIUtils: "resource:///modules/PlacesUIUtils.sys.mjs", + SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs", +}); + +XPCOMUtils.defineLazyGetter(lazy, "l10n", () => { + return new Localization(["browser/recentlyClosed.ftl"], true); +}); + +export var RecentlyClosedTabsAndWindowsMenuUtils = { + /** + * Builds up a document fragment of UI items for the recently closed tabs. + * @param aWindow + * The window that the tabs were closed in. + * @param aTagName + * The tag name that will be used when creating the UI items. + * @param aPrefixRestoreAll (defaults to false) + * Whether the 'restore all tabs' item is suffixed or prefixed to the list. + * If suffixed (the default) a separator will be inserted before it. + * @returns A document fragment with UI items for each recently closed tab. + */ + getTabsFragment(aWindow, aTagName, aPrefixRestoreAll = false) { + let doc = aWindow.document; + let fragment = doc.createDocumentFragment(); + if (lazy.SessionStore.getClosedTabCountForWindow(aWindow) != 0) { + let closedTabs = lazy.SessionStore.getClosedTabDataForWindow(aWindow); + for (let i = 0; i < closedTabs.length; i++) { + createEntry( + aTagName, + false, + i, + closedTabs[i], + doc, + closedTabs[i].title, + fragment + ); + } + + createRestoreAllEntry( + doc, + fragment, + aPrefixRestoreAll, + false, + aTagName == "menuitem" + ? "recently-closed-menu-reopen-all-tabs" + : "recently-closed-panel-reopen-all-tabs", + closedTabs.length, + aTagName + ); + } + return fragment; + }, + + /** + * Builds up a document fragment of UI items for the recently closed windows. + * @param aWindow + * A window that can be used to create the elements and document fragment. + * @param aTagName + * The tag name that will be used when creating the UI items. + * @param aPrefixRestoreAll (defaults to false) + * Whether the 'restore all windows' item is suffixed or prefixed to the list. + * If suffixed (the default) a separator will be inserted before it. + * @returns A document fragment with UI items for each recently closed window. + */ + getWindowsFragment(aWindow, aTagName, aPrefixRestoreAll = false) { + let closedWindowData = lazy.SessionStore.getClosedWindowData(); + let doc = aWindow.document; + let fragment = doc.createDocumentFragment(); + if (closedWindowData.length) { + for (let i = 0; i < closedWindowData.length; i++) { + const { selected, tabs, title } = closedWindowData[i]; + const selectedTab = tabs[selected - 1]; + if (selectedTab) { + const menuLabel = lazy.l10n.formatValueSync( + "recently-closed-undo-close-window-label", + { tabCount: tabs.length - 1, winTitle: title } + ); + createEntry(aTagName, true, i, selectedTab, doc, menuLabel, fragment); + } + } + + createRestoreAllEntry( + doc, + fragment, + aPrefixRestoreAll, + true, + aTagName == "menuitem" + ? "recently-closed-menu-reopen-all-windows" + : "recently-closed-panel-reopen-all-windows", + closedWindowData.length, + aTagName + ); + } + return fragment; + }, + + /** + * Re-open a closed tab and put it to the end of the tab strip. + * Used for a middle click. + * @param aEvent + * The event when the user clicks the menu item + */ + _undoCloseMiddleClick(aEvent) { + if (aEvent.button != 1) { + return; + } + + aEvent.view.undoCloseTab(aEvent.originalTarget.getAttribute("value")); + aEvent.view.gBrowser.moveTabToEnd(); + let ancestorPanel = aEvent.target.closest("panel"); + if (ancestorPanel) { + ancestorPanel.hidePopup(); + } + }, +}; + +/** + * Create a UI entry for a recently closed tab or window. + * @param aTagName + * the tag name that will be used when creating the UI entry + * @param aIsWindowsFragment + * whether or not this entry will represent a closed window + * @param aIndex + * the index of the closed tab + * @param aClosedTab + * the closed tab + * @param aDocument + * a document that can be used to create the entry + * @param aMenuLabel + * the label the created entry will have + * @param aFragment + * the fragment the created entry will be in + */ +function createEntry( + aTagName, + aIsWindowsFragment, + aIndex, + aClosedTab, + aDocument, + aMenuLabel, + aFragment +) { + let element = aDocument.createXULElement(aTagName); + + element.setAttribute("label", aMenuLabel); + if (aClosedTab.image) { + const iconURL = lazy.PlacesUIUtils.getImageURL(aClosedTab.image); + element.setAttribute("image", iconURL); + } + if (!aIsWindowsFragment) { + element.setAttribute("value", aIndex); + } + + if (aTagName == "menuitem") { + element.setAttribute( + "class", + "menuitem-iconic bookmark-item menuitem-with-favicon" + ); + } + + element.setAttribute( + "oncommand", + "undoClose" + (aIsWindowsFragment ? "Window" : "Tab") + "(" + aIndex + ");" + ); + + // Set the targetURI attribute so it will be shown in tooltip. + // SessionStore uses one-based indexes, so we need to normalize them. + let tabData; + tabData = aIsWindowsFragment ? aClosedTab : aClosedTab.state; + let activeIndex = (tabData.index || tabData.entries.length) - 1; + if (activeIndex >= 0 && tabData.entries[activeIndex]) { + element.setAttribute("targetURI", tabData.entries[activeIndex].url); + } + + // Windows don't open in new tabs and menuitems dispatch command events on + // middle click, so we only need to manually handle middle clicks for + // toolbarbuttons. + if (!aIsWindowsFragment && aTagName != "menuitem") { + element.addEventListener( + "click", + RecentlyClosedTabsAndWindowsMenuUtils._undoCloseMiddleClick + ); + } + + if (aIndex == 0) { + element.setAttribute( + "key", + "key_undoClose" + (aIsWindowsFragment ? "Window" : "Tab") + ); + } + + aFragment.appendChild(element); +} + +/** + * Create an entry to restore all closed windows or tabs. + * @param aDocument + * a document that can be used to create the entry + * @param aFragment + * the fragment the created entry will be in + * @param aPrefixRestoreAll + * whether the 'restore all windows' item is suffixed or prefixed to the list + * If suffixed a separator will be inserted before it. + * @param aIsWindowsFragment + * whether or not this entry will represent a closed window + * @param aRestoreAllLabel + * which localizable string to use for the entry + * @param aEntryCount + * the number of elements to be restored by this entry + * @param aTagName + * the tag name that will be used when creating the UI entry + */ +function createRestoreAllEntry( + aDocument, + aFragment, + aPrefixRestoreAll, + aIsWindowsFragment, + aRestoreAllLabel, + aEntryCount, + aTagName +) { + let restoreAllElements = aDocument.createXULElement(aTagName); + restoreAllElements.classList.add("restoreallitem"); + + // We cannot use aDocument.l10n.setAttributes because the menubar label is not + // updated in time and displays a blank string (see Bug 1691553). + restoreAllElements.setAttribute( + "label", + lazy.l10n.formatValueSync(aRestoreAllLabel) + ); + + restoreAllElements.setAttribute( + "oncommand", + "for (var i = 0; i < " + + aEntryCount + + "; i++) undoClose" + + (aIsWindowsFragment ? "Window" : "Tab") + + "();" + ); + if (aPrefixRestoreAll) { + aFragment.insertBefore(restoreAllElements, aFragment.firstChild); + } else { + aFragment.appendChild(aDocument.createXULElement("menuseparator")); + aFragment.appendChild(restoreAllElements); + } +} diff --git a/browser/components/sessionstore/RunState.sys.mjs b/browser/components/sessionstore/RunState.sys.mjs new file mode 100644 index 0000000000..94f9a86fcd --- /dev/null +++ b/browser/components/sessionstore/RunState.sys.mjs @@ -0,0 +1,92 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 STATE_STOPPED = 0; +const STATE_RUNNING = 1; +const STATE_QUITTING = 2; +const STATE_CLOSING = 3; +const STATE_CLOSED = 4; + +// We're initially stopped. +var state = STATE_STOPPED; + +/** + * This module keeps track of SessionStore's current run state. We will + * always start out at STATE_STOPPED. After the session was read from disk and + * the initial browser window has loaded we switch to STATE_RUNNING. On the + * first notice that a browser shutdown was granted we switch to STATE_QUITTING. + */ +export var RunState = Object.freeze({ + // If we're stopped then SessionStore hasn't been initialized yet. As soon + // as the session is read from disk and the initial browser window has loaded + // the run state will change to STATE_RUNNING. + get isStopped() { + return state == STATE_STOPPED; + }, + + // STATE_RUNNING is our default mode of operation that we'll spend most of + // the time in. After the session was read from disk and the first browser + // window has loaded we remain running until the browser quits. + get isRunning() { + return state == STATE_RUNNING; + }, + + // We will enter STATE_QUITTING as soon as we receive notice that a browser + // shutdown was granted. SessionStore will use this information to prevent + // us from collecting partial information while the browser is shutting down + // as well as to allow a last single write to disk and block all writes after + // that. + get isQuitting() { + return state >= STATE_QUITTING; + }, + + // We will enter STATE_CLOSING as soon as SessionStore is uninitialized. + // The SessionFile module will know that a last write will happen in this + // state and it can do some necessary cleanup. + get isClosing() { + return state == STATE_CLOSING; + }, + + // We will enter STATE_CLOSED as soon as SessionFile has written to disk for + // the last time before shutdown and will not accept any further writes. + get isClosed() { + return state == STATE_CLOSED; + }, + + // Switch the run state to STATE_RUNNING. This must be called after the + // session was read from, the initial browser window has loaded and we're + // now ready to restore session data. + setRunning() { + if (this.isStopped) { + state = STATE_RUNNING; + } + }, + + // Switch the run state to STATE_CLOSING. This must be called *before* the + // last SessionFile.write() call so that SessionFile knows we're closing and + // can do some last cleanups and write a proper sessionstore.js file. + setClosing() { + if (this.isQuitting) { + state = STATE_CLOSING; + } + }, + + // Switch the run state to STATE_CLOSED. This must be called by SessionFile + // after the last write to disk was accepted and no further writes will be + // allowed. Any writes after this stage will cause exceptions. + setClosed() { + if (this.isClosing) { + state = STATE_CLOSED; + } + }, + + // Switch the run state to STATE_QUITTING. This should be called once we're + // certain that the browser is going away and before we start collecting the + // final window states to save in the session file. + setQuitting() { + if (this.isRunning) { + state = STATE_QUITTING; + } + }, +}); diff --git a/browser/components/sessionstore/SessionCookies.sys.mjs b/browser/components/sessionstore/SessionCookies.sys.mjs new file mode 100644 index 0000000000..f391c0c437 --- /dev/null +++ b/browser/components/sessionstore/SessionCookies.sys.mjs @@ -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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + PrivacyLevel: "resource://gre/modules/sessionstore/PrivacyLevel.sys.mjs", +}); + +const MAX_EXPIRY = Number.MAX_SAFE_INTEGER; + +/** + * The external API implemented by the SessionCookies module. + */ +export var SessionCookies = Object.freeze({ + collect() { + return SessionCookiesInternal.collect(); + }, + + restore(cookies) { + SessionCookiesInternal.restore(cookies); + }, +}); + +/** + * The internal API. + */ +var SessionCookiesInternal = { + /** + * Stores whether we're initialized, yet. + */ + _initialized: false, + + /** + * Retrieve an array of all stored session cookies. + */ + collect() { + this._ensureInitialized(); + return CookieStore.toArray(); + }, + + /** + * Restores a given list of session cookies. + */ + restore(cookies) { + for (let cookie of cookies) { + let expiry = "expiry" in cookie ? cookie.expiry : MAX_EXPIRY; + let exists = false; + try { + exists = Services.cookies.cookieExists( + cookie.host, + cookie.path || "", + cookie.name || "", + cookie.originAttributes || {} + ); + } catch (ex) { + console.error( + `CookieService::CookieExists failed with error '${ex}' for '${JSON.stringify( + cookie + )}'.` + ); + } + if (!exists) { + try { + Services.cookies.add( + cookie.host, + cookie.path || "", + cookie.name || "", + cookie.value, + !!cookie.secure, + !!cookie.httponly, + /* isSession = */ true, + expiry, + cookie.originAttributes || {}, + cookie.sameSite || Ci.nsICookie.SAMESITE_NONE, + cookie.schemeMap || Ci.nsICookie.SCHEME_HTTPS + ); + } catch (ex) { + console.error( + `CookieService::Add failed with error '${ex}' for cookie ${JSON.stringify( + cookie + )}.` + ); + } + } + } + }, + + /** + * Handles observers notifications that are sent whenever cookies are added, + * changed, or removed. Ensures that the storage is updated accordingly. + */ + observe(subject, topic, data) { + switch (data) { + case "added": + this._addCookie(subject); + break; + case "changed": + this._updateCookie(subject); + break; + case "deleted": + this._removeCookie(subject); + break; + case "cleared": + CookieStore.clear(); + break; + case "batch-deleted": + this._removeCookies(subject); + break; + default: + throw new Error("Unhandled session-cookie-changed notification."); + } + }, + + /** + * If called for the first time in a session, iterates all cookies in the + * cookies service and puts them into the store if they're session cookies. + */ + _ensureInitialized() { + if (this._initialized) { + return; + } + this._reloadCookies(); + this._initialized = true; + Services.obs.addObserver(this, "session-cookie-changed"); + + // Listen for privacy level changes to reload cookies when needed. + Services.prefs.addObserver("browser.sessionstore.privacy_level", () => { + this._reloadCookies(); + }); + }, + + /** + * Adds a given cookie to the store. + */ + _addCookie(cookie) { + cookie.QueryInterface(Ci.nsICookie); + + // Store only session cookies, obey the privacy level. + if (cookie.isSession && lazy.PrivacyLevel.canSave(cookie.isSecure)) { + CookieStore.add(cookie); + } + }, + + /** + * Updates a given cookie. + */ + _updateCookie(cookie) { + cookie.QueryInterface(Ci.nsICookie); + + // Store only session cookies, obey the privacy level. + if (cookie.isSession && lazy.PrivacyLevel.canSave(cookie.isSecure)) { + CookieStore.add(cookie); + } else { + CookieStore.delete(cookie); + } + }, + + /** + * Removes a given cookie from the store. + */ + _removeCookie(cookie) { + cookie.QueryInterface(Ci.nsICookie); + + if (cookie.isSession) { + CookieStore.delete(cookie); + } + }, + + /** + * Removes a given list of cookies from the store. + */ + _removeCookies(cookies) { + for (let i = 0; i < cookies.length; i++) { + this._removeCookie(cookies.queryElementAt(i, Ci.nsICookie)); + } + }, + + /** + * Iterates all cookies in the cookies service and puts them into the store + * if they're session cookies. Obeys the user's chosen privacy level. + */ + _reloadCookies() { + CookieStore.clear(); + + // Bail out if we're not supposed to store cookies at all. + if (!lazy.PrivacyLevel.canSave(false)) { + return; + } + + for (let cookie of Services.cookies.sessionCookies) { + this._addCookie(cookie); + } + }, +}; + +/** + * The internal storage that keeps track of session cookies. + */ +var CookieStore = { + /** + * The internal map holding all known session cookies. + */ + _entries: new Map(), + + /** + * Stores a given cookie. + * + * @param cookie + * The nsICookie object to add to the storage. + */ + add(cookie) { + let jscookie = { host: cookie.host, value: cookie.value }; + + // Only add properties with non-default values to save a few bytes. + if (cookie.path) { + jscookie.path = cookie.path; + } + + if (cookie.name) { + jscookie.name = cookie.name; + } + + if (cookie.isSecure) { + jscookie.secure = true; + } + + if (cookie.isHttpOnly) { + jscookie.httponly = true; + } + + if (cookie.expiry < MAX_EXPIRY) { + jscookie.expiry = cookie.expiry; + } + + if (cookie.originAttributes) { + jscookie.originAttributes = cookie.originAttributes; + } + + if (cookie.sameSite) { + jscookie.sameSite = cookie.sameSite; + } + + if (cookie.schemeMap) { + jscookie.schemeMap = cookie.schemeMap; + } + + this._entries.set(this._getKeyForCookie(cookie), jscookie); + }, + + /** + * Removes a given cookie. + * + * @param cookie + * The nsICookie object to be removed from storage. + */ + delete(cookie) { + this._entries.delete(this._getKeyForCookie(cookie)); + }, + + /** + * Removes all cookies. + */ + clear() { + this._entries.clear(); + }, + + /** + * Return all cookies as an array. + */ + toArray() { + return [...this._entries.values()]; + }, + + /** + * Returns the key needed to properly store and identify a given cookie. + * A cookie is uniquely identified by the combination of its host, name, + * path, and originAttributes properties. + * + * @param cookie + * The nsICookie object to compute a key for. + * @return string + */ + _getKeyForCookie(cookie) { + return JSON.stringify({ + host: cookie.host, + name: cookie.name, + path: cookie.path, + attr: ChromeUtils.originAttributesToSuffix(cookie.originAttributes), + }); + }, +}; diff --git a/browser/components/sessionstore/SessionFile.sys.mjs b/browser/components/sessionstore/SessionFile.sys.mjs new file mode 100644 index 0000000000..1e5a3bf718 --- /dev/null +++ b/browser/components/sessionstore/SessionFile.sys.mjs @@ -0,0 +1,467 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Implementation of all the disk I/O required by the session store. + * This is a private API, meant to be used only by the session store. + * It will change. Do not use it for any other purpose. + * + * Note that this module depends on SessionWriter and that it enqueues its I/O + * requests and never attempts to simultaneously execute two I/O requests on + * the files used by this module from two distinct threads. + * Otherwise, we could encounter bugs, especially under Windows, + * e.g. if a request attempts to write sessionstore.js while + * another attempts to copy that file. + */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + RunState: "resource:///modules/sessionstore/RunState.sys.mjs", + SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs", + SessionWriter: "resource:///modules/sessionstore/SessionWriter.sys.mjs", +}); + +const PREF_UPGRADE_BACKUP = "browser.sessionstore.upgradeBackup.latestBuildID"; +const PREF_MAX_UPGRADE_BACKUPS = + "browser.sessionstore.upgradeBackup.maxUpgradeBackups"; + +const PREF_MAX_SERIALIZE_BACK = "browser.sessionstore.max_serialize_back"; +const PREF_MAX_SERIALIZE_FWD = "browser.sessionstore.max_serialize_forward"; + +export var SessionFile = { + /** + * Read the contents of the session file, asynchronously. + */ + read() { + return SessionFileInternal.read(); + }, + /** + * Write the contents of the session file, asynchronously. + * @param aData - May get changed on shutdown. + */ + write(aData) { + return SessionFileInternal.write(aData); + }, + /** + * Wipe the contents of the session file, asynchronously. + */ + wipe() { + return SessionFileInternal.wipe(); + }, + + /** + * Return the paths to the files used to store, backup, etc. + * the state of the file. + */ + get Paths() { + return SessionFileInternal.Paths; + }, +}; + +Object.freeze(SessionFile); + +const profileDir = PathUtils.profileDir; + +var SessionFileInternal = { + Paths: Object.freeze({ + // The path to the latest version of sessionstore written during a clean + // shutdown. After startup, it is renamed `cleanBackup`. + clean: PathUtils.join(profileDir, "sessionstore.jsonlz4"), + + // The path at which we store the previous version of `clean`. Updated + // whenever we successfully load from `clean`. + cleanBackup: PathUtils.join( + profileDir, + "sessionstore-backups", + "previous.jsonlz4" + ), + + // The directory containing all sessionstore backups. + backups: PathUtils.join(profileDir, "sessionstore-backups"), + + // The path to the latest version of the sessionstore written + // during runtime. Generally, this file contains more + // privacy-sensitive information than |clean|, and this file is + // therefore removed during clean shutdown. This file is designed to protect + // against crashes / sudden shutdown. + recovery: PathUtils.join( + profileDir, + "sessionstore-backups", + "recovery.jsonlz4" + ), + + // The path to the previous version of the sessionstore written + // during runtime (e.g. 15 seconds before recovery). In case of a + // clean shutdown, this file is removed. Generally, this file + // contains more privacy-sensitive information than |clean|, and + // this file is therefore removed during clean shutdown. This + // file is designed to protect against crashes that are nasty + // enough to corrupt |recovery|. + recoveryBackup: PathUtils.join( + profileDir, + "sessionstore-backups", + "recovery.baklz4" + ), + + // The path to a backup created during an upgrade of Firefox. + // Having this backup protects the user essentially from bugs in + // Firefox or add-ons, especially for users of Nightly. This file + // does not contain any information more sensitive than |clean|. + upgradeBackupPrefix: PathUtils.join( + profileDir, + "sessionstore-backups", + "upgrade.jsonlz4-" + ), + + // The path to the backup of the version of the session store used + // during the latest upgrade of Firefox. During load/recovery, + // this file should be used if both |path|, |backupPath| and + // |latestStartPath| are absent/incorrect. May be "" if no + // upgrade backup has ever been performed. This file does not + // contain any information more sensitive than |clean|. + get upgradeBackup() { + let latestBackupID = SessionFileInternal.latestUpgradeBackupID; + if (!latestBackupID) { + return ""; + } + return this.upgradeBackupPrefix + latestBackupID; + }, + + // The path to a backup created during an upgrade of Firefox. + // Having this backup protects the user essentially from bugs in + // Firefox, especially for users of Nightly. + get nextUpgradeBackup() { + return this.upgradeBackupPrefix + Services.appinfo.platformBuildID; + }, + + /** + * The order in which to search for a valid sessionstore file. + */ + get loadOrder() { + // If `clean` exists and has been written without corruption during + // the latest shutdown, we need to use it. + // + // Otherwise, `recovery` and `recoveryBackup` represent the most + // recent state of the session store. + // + // Finally, if nothing works, fall back to the last known state + // that can be loaded (`cleanBackup`) or, if available, to the + // backup performed during the latest upgrade. + let order = ["clean", "recovery", "recoveryBackup", "cleanBackup"]; + if (SessionFileInternal.latestUpgradeBackupID) { + // We have an upgradeBackup + order.push("upgradeBackup"); + } + return order; + }, + }), + + // Number of attempted calls to `write`. + // Note that we may have _attempts > _successes + _failures, + // if attempts never complete. + // Used for error reporting. + _attempts: 0, + + // Number of successful calls to `write`. + // Used for error reporting. + _successes: 0, + + // Number of failed calls to `write`. + // Used for error reporting. + _failures: 0, + + // `true` once we have initialized SessionWriter. + _initialized: false, + + // A string that will be set to the session file name part that was read from + // disk. It will be available _after_ a session file read() is done. + _readOrigin: null, + + // `true` if the old, uncompressed, file format was used to read from disk, as + // a fallback mechanism. + _usingOldExtension: false, + + // The ID of the latest version of Gecko for which we have an upgrade backup + // or |undefined| if no upgrade backup was ever written. + get latestUpgradeBackupID() { + try { + return Services.prefs.getCharPref(PREF_UPGRADE_BACKUP); + } catch (ex) { + return undefined; + } + }, + + async _readInternal(useOldExtension) { + let result; + let noFilesFound = true; + this._usingOldExtension = useOldExtension; + + // Attempt to load by order of priority from the various backups + for (let key of this.Paths.loadOrder) { + let corrupted = false; + let exists = true; + try { + let path; + let startMs = Date.now(); + + let options = {}; + if (useOldExtension) { + path = this.Paths[key] + .replace("jsonlz4", "js") + .replace("baklz4", "bak"); + } else { + path = this.Paths[key]; + options.decompress = true; + } + let source = await IOUtils.readUTF8(path, options); + let parsed = JSON.parse(source); + + if (parsed._cachedObjs) { + try { + let cacheMap = new Map(parsed._cachedObjs); + for (let win of parsed.windows.concat( + parsed._closedWindows || [] + )) { + for (let tab of win.tabs.concat(win._closedTabs || [])) { + tab.image = cacheMap.get(tab.image) || tab.image; + } + } + } catch (e) { + // This is temporary code to clean up after the backout of bug + // 1546847. Just in case there are problems in the format of + // the parsed data, continue on. Favicons might be broken, but + // the session will at least be recovered + console.error(e); + } + } + + if ( + !lazy.SessionStore.isFormatVersionCompatible( + parsed.version || [ + "sessionrestore", + 0, + ] /* fallback for old versions*/ + ) + ) { + // Skip sessionstore files that we don't understand. + console.error( + "Cannot extract data from Session Restore file ", + path, + ". Wrong format/version: " + JSON.stringify(parsed.version) + "." + ); + continue; + } + result = { + origin: key, + source, + parsed, + useOldExtension, + }; + Services.telemetry + .getHistogramById("FX_SESSION_RESTORE_CORRUPT_FILE") + .add(false); + Services.telemetry + .getHistogramById("FX_SESSION_RESTORE_READ_FILE_MS") + .add(Date.now() - startMs); + break; + } catch (ex) { + if (DOMException.isInstance(ex) && ex.name == "NotFoundError") { + exists = false; + } else if ( + DOMException.isInstance(ex) && + ex.name == "NotAllowedError" + ) { + // The file might be inaccessible due to wrong permissions + // or similar failures. We'll just count it as "corrupted". + console.error("Could not read session file ", ex); + corrupted = true; + } else if (ex instanceof SyntaxError) { + console.error( + "Corrupt session file (invalid JSON found) ", + ex, + ex.stack + ); + // File is corrupted, try next file + corrupted = true; + } + } finally { + if (exists) { + noFilesFound = false; + Services.telemetry + .getHistogramById("FX_SESSION_RESTORE_CORRUPT_FILE") + .add(corrupted); + } + } + } + return { result, noFilesFound }; + }, + + // Find the correct session file and read it. + async read() { + // Load session files with lz4 compression. + let { result, noFilesFound } = await this._readInternal(false); + if (!result) { + // No result? Probably because of migration, let's + // load uncompressed session files. + let r = await this._readInternal(true); + result = r.result; + } + + // All files are corrupted if files found but none could deliver a result. + let allCorrupt = !noFilesFound && !result; + Services.telemetry + .getHistogramById("FX_SESSION_RESTORE_ALL_FILES_CORRUPT") + .add(allCorrupt); + + if (!result) { + // If everything fails, start with an empty session. + result = { + origin: "empty", + source: "", + parsed: null, + useOldExtension: false, + }; + } + this._readOrigin = result.origin; + + result.noFilesFound = noFilesFound; + + return result; + }, + + // Initialize SessionWriter and return it as a resolved promise. + getWriter() { + if (!this._initialized) { + if (!this._readOrigin) { + return Promise.reject( + "SessionFileInternal.getWriter() called too early! Please read the session file from disk first." + ); + } + + this._initialized = true; + lazy.SessionWriter.init( + this._readOrigin, + this._usingOldExtension, + this.Paths, + { + maxUpgradeBackups: Services.prefs.getIntPref( + PREF_MAX_UPGRADE_BACKUPS, + 3 + ), + maxSerializeBack: Services.prefs.getIntPref( + PREF_MAX_SERIALIZE_BACK, + 10 + ), + maxSerializeForward: Services.prefs.getIntPref( + PREF_MAX_SERIALIZE_FWD, + -1 + ), + } + ); + } + + return Promise.resolve(lazy.SessionWriter); + }, + + write(aData) { + if (lazy.RunState.isClosed) { + return Promise.reject(new Error("SessionFile is closed")); + } + + let isFinalWrite = false; + if (lazy.RunState.isClosing) { + // If shutdown has started, we will want to stop receiving + // write instructions. + isFinalWrite = true; + lazy.RunState.setClosed(); + } + + let performShutdownCleanup = + isFinalWrite && !lazy.SessionStore.willAutoRestore; + + this._attempts++; + let options = { isFinalWrite, performShutdownCleanup }; + let promise = this.getWriter().then(writer => writer.write(aData, options)); + + // Wait until the write is done. + promise = promise.then( + msg => { + // Record how long the write took. + this._recordTelemetry(msg.telemetry); + this._successes++; + if (msg.result.upgradeBackup) { + // We have just completed a backup-on-upgrade, store the information + // in preferences. + Services.prefs.setCharPref( + PREF_UPGRADE_BACKUP, + Services.appinfo.platformBuildID + ); + } + }, + err => { + // Catch and report any errors. + console.error("Could not write session state file ", err, err.stack); + this._failures++; + // By not doing anything special here we ensure that |promise| cannot + // be rejected anymore. The shutdown/cleanup code at the end of the + // function will thus always be executed. + } + ); + + // Ensure that we can write sessionstore.js cleanly before the profile + // becomes unaccessible. + IOUtils.profileBeforeChange.addBlocker( + "SessionFile: Finish writing Session Restore data", + promise, + { + fetchState: () => ({ + options, + attempts: this._attempts, + successes: this._successes, + failures: this._failures, + }), + } + ); + + // This code will always be executed because |promise| can't fail anymore. + // We ensured that by having a reject handler that reports the failure but + // doesn't forward the rejection. + return promise.then(() => { + // Remove the blocker, no matter if writing failed or not. + IOUtils.profileBeforeChange.removeBlocker(promise); + + if (isFinalWrite) { + Services.obs.notifyObservers( + null, + "sessionstore-final-state-write-complete" + ); + } + }); + }, + + async wipe() { + const writer = await this.getWriter(); + await writer.wipe(); + // After a wipe, we need to make sure to re-initialize upon the next read(), + // because the state variables as sent to the writer have changed. + this._initialized = false; + }, + + _recordTelemetry(telemetry) { + for (let id of Object.keys(telemetry)) { + let value = telemetry[id]; + let samples = []; + if (Array.isArray(value)) { + samples.push(...value); + } else { + samples.push(value); + } + let histogram = Services.telemetry.getHistogramById(id); + for (let sample of samples) { + histogram.add(sample); + } + } + }, +}; diff --git a/browser/components/sessionstore/SessionMigration.sys.mjs b/browser/components/sessionstore/SessionMigration.sys.mjs new file mode 100644 index 0000000000..7f2548890d --- /dev/null +++ b/browser/components/sessionstore/SessionMigration.sys.mjs @@ -0,0 +1,92 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + E10SUtils: "resource://gre/modules/E10SUtils.sys.mjs", +}); + +var SessionMigrationInternal = { + /** + * Convert the original session restore state into a minimal state. It will + * only contain: + * - open windows + * - with tabs + * - with history entries with only title, url, triggeringPrincipal + * - with pinned state + * - with tab group info (hidden + group id) + * - with selected tab info + * - with selected window info + * + * The complete state is then wrapped into the "about:welcomeback" page as + * form field info to be restored when restoring the state. + */ + convertState(aStateObj) { + let state = { + selectedWindow: aStateObj.selectedWindow, + _closedWindows: [], + }; + state.windows = aStateObj.windows.map(function (oldWin) { + var win = { extData: {} }; + win.tabs = oldWin.tabs.map(function (oldTab) { + var tab = {}; + // Keep only titles, urls and triggeringPrincipals for history entries + tab.entries = oldTab.entries.map(function (entry) { + return { + url: entry.url, + triggeringPrincipal_base64: entry.triggeringPrincipal_base64, + title: entry.title, + }; + }); + tab.index = oldTab.index; + tab.hidden = oldTab.hidden; + tab.pinned = oldTab.pinned; + return tab; + }); + win.selected = oldWin.selected; + win._closedTabs = []; + return win; + }); + let url = "about:welcomeback"; + let formdata = { id: { sessionData: state }, url }; + let entry = { + url, + triggeringPrincipal_base64: lazy.E10SUtils.SERIALIZED_SYSTEMPRINCIPAL, + }; + return { windows: [{ tabs: [{ entries: [entry], formdata }] }] }; + }, + /** + * Asynchronously read session restore state (JSON) from a path + */ + readState(aPath) { + return IOUtils.readJSON(aPath, { decompress: true }); + }, + /** + * Asynchronously write session restore state as JSON to a path + */ + writeState(aPath, aState) { + return IOUtils.writeJSON(aPath, aState, { + compress: true, + tmpPath: `${aPath}.tmp`, + }); + }, +}; + +export var SessionMigration = { + /** + * Migrate a limited set of session data from one path to another. + */ + migrate(aFromPath, aToPath) { + return (async function () { + let inState = await SessionMigrationInternal.readState(aFromPath); + let outState = SessionMigrationInternal.convertState(inState); + // Unfortunately, we can't use SessionStore's own SessionFile to + // write out the data because it has a dependency on the profile dir + // being known. When the migration runs, there is no guarantee that + // that's true. + await SessionMigrationInternal.writeState(aToPath, outState); + })(); + }, +}; diff --git a/browser/components/sessionstore/SessionSaver.sys.mjs b/browser/components/sessionstore/SessionSaver.sys.mjs new file mode 100644 index 0000000000..2f08bb2243 --- /dev/null +++ b/browser/components/sessionstore/SessionSaver.sys.mjs @@ -0,0 +1,405 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { + cancelIdleCallback, + clearTimeout, + requestIdleCallback, + setTimeout, +} from "resource://gre/modules/Timer.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + PrivacyFilter: "resource://gre/modules/sessionstore/PrivacyFilter.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", + RunState: "resource:///modules/sessionstore/RunState.sys.mjs", + SessionFile: "resource:///modules/sessionstore/SessionFile.sys.mjs", + SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs", +}); + +/* + * Minimal interval between two save operations (in milliseconds). + * + * To save system resources, we generally do not save changes immediately when + * a change is detected. Rather, we wait a little to see if this change is + * followed by other changes, in which case only the last write is necessary. + * This delay is defined by "browser.sessionstore.interval". + * + * Furthermore, when the user is not actively using the computer, webpages + * may still perform changes that require (re)writing to sessionstore, e.g. + * updating Session Cookies or DOM Session Storage, or refreshing, etc. We + * expect that these changes are much less critical to the user and do not + * need to be saved as often. In such cases, we increase the delay to + * "browser.sessionstore.interval.idle". + * + * When the user returns to the computer, if a save is pending, we reschedule + * it to happen soon, with "browser.sessionstore.interval". + */ +const PREF_INTERVAL_ACTIVE = "browser.sessionstore.interval"; +const PREF_INTERVAL_IDLE = "browser.sessionstore.interval.idle"; +const PREF_IDLE_DELAY = "browser.sessionstore.idleDelay"; + +// Notify observers about a given topic with a given subject. +function notify(subject, topic) { + Services.obs.notifyObservers(subject, topic); +} + +// TelemetryStopwatch helper functions. +function stopWatch(method) { + return function (...histograms) { + for (let hist of histograms) { + TelemetryStopwatch[method]("FX_SESSION_RESTORE_" + hist); + } + }; +} + +var stopWatchStart = stopWatch("start"); +var stopWatchFinish = stopWatch("finish"); + +/** + * The external API implemented by the SessionSaver module. + */ +export var SessionSaver = Object.freeze({ + /** + * Immediately saves the current session to disk. + */ + run() { + return SessionSaverInternal.run(); + }, + + /** + * Saves the current session to disk delayed by a given amount of time. Should + * another delayed run be scheduled already, we will ignore the given delay + * and state saving may occur a little earlier. + */ + runDelayed() { + SessionSaverInternal.runDelayed(); + }, + + /** + * Sets the last save time to the current time. This will cause us to wait for + * at least the configured interval when runDelayed() is called next. + */ + updateLastSaveTime() { + SessionSaverInternal.updateLastSaveTime(); + }, + + /** + * Cancels all pending session saves. + */ + cancel() { + SessionSaverInternal.cancel(); + }, +}); + +/** + * The internal API. + */ +var SessionSaverInternal = { + /** + * The timeout ID referencing an active timer for a delayed save. When no + * save is pending, this is null. + */ + _timeoutID: null, + + /** + * The idle callback ID referencing an active idle callback. When no idle + * callback is pending, this is null. + * */ + _idleCallbackID: null, + + /** + * A timestamp that keeps track of when we saved the session last. We will + * this to determine the correct interval between delayed saves to not deceed + * the configured session write interval. + */ + _lastSaveTime: 0, + + /** + * `true` if the user has been idle for at least + * `SessionSaverInternal._intervalWhileIdle` ms. Idleness is computed + * with `nsIUserIdleService`. + */ + _isIdle: false, + + /** + * `true` if the user was idle when we last scheduled a delayed save. + * See `_isIdle` for details on idleness. + */ + _wasIdle: false, + + /** + * Minimal interval between two save operations (in ms), while the user + * is active. + */ + _intervalWhileActive: null, + + /** + * Minimal interval between two save operations (in ms), while the user + * is idle. + */ + _intervalWhileIdle: null, + + /** + * How long before we assume that the user is idle (ms). + */ + _idleDelay: null, + + /** + * Immediately saves the current session to disk. + */ + run() { + return this._saveState(true /* force-update all windows */); + }, + + /** + * Saves the current session to disk delayed by a given amount of time. Should + * another delayed run be scheduled already, we will ignore the given delay + * and state saving may occur a little earlier. + * + * @param delay (optional) + * The minimum delay in milliseconds to wait for until we collect and + * save the current session. + */ + runDelayed(delay = 2000) { + // Bail out if there's a pending run. + if (this._timeoutID) { + return; + } + + // Interval until the next disk operation is allowed. + let interval = this._isIdle + ? this._intervalWhileIdle + : this._intervalWhileActive; + delay = Math.max(this._lastSaveTime + interval - Date.now(), delay, 0); + + // Schedule a state save. + this._wasIdle = this._isIdle; + this._timeoutID = setTimeout(() => { + // Execute _saveStateAsync when we have idle time. + let saveStateAsyncWhenIdle = () => { + this._saveStateAsync(); + }; + + this._idleCallbackID = requestIdleCallback(saveStateAsyncWhenIdle); + }, delay); + }, + + /** + * Sets the last save time to the current time. This will cause us to wait for + * at least the configured interval when runDelayed() is called next. + */ + updateLastSaveTime() { + this._lastSaveTime = Date.now(); + }, + + /** + * Cancels all pending session saves. + */ + cancel() { + clearTimeout(this._timeoutID); + this._timeoutID = null; + cancelIdleCallback(this._idleCallbackID); + this._idleCallbackID = null; + }, + + /** + * Observe idle/ active notifications. + */ + observe(subject, topic, data) { + switch (topic) { + case "idle": + this._isIdle = true; + break; + case "active": + this._isIdle = false; + if (this._timeoutID && this._wasIdle) { + // A state save has been scheduled while we were idle. + // Replace it by an active save. + clearTimeout(this._timeoutID); + this._timeoutID = null; + this.runDelayed(); + } + break; + default: + throw new Error(`Unexpected change value ${topic}`); + } + }, + + /** + * Saves the current session state. Collects data and writes to disk. + * + * @param forceUpdateAllWindows (optional) + * Forces us to recollect data for all windows and will bypass and + * update the corresponding caches. + */ + _saveState(forceUpdateAllWindows = false) { + // Cancel any pending timeouts. + this.cancel(); + + if (lazy.PrivateBrowsingUtils.permanentPrivateBrowsing) { + // Don't save (or even collect) anything in permanent private + // browsing mode + + this.updateLastSaveTime(); + return Promise.resolve(); + } + + stopWatchStart("COLLECT_DATA_MS"); + let state = lazy.SessionStore.getCurrentState(forceUpdateAllWindows); + lazy.PrivacyFilter.filterPrivateWindowsAndTabs(state); + + // Make sure we only write worth saving tabs to disk. + lazy.SessionStore.keepOnlyWorthSavingTabs(state); + + // Make sure that we keep the previous session if we started with a single + // private window and no non-private windows have been opened, yet. + if (state.deferredInitialState) { + state.windows = state.deferredInitialState.windows || []; + delete state.deferredInitialState; + } + + if (AppConstants.platform != "macosx") { + // We want to restore closed windows that are marked with _shouldRestore. + // We're doing this here because we want to control this only when saving + // the file. + while (state._closedWindows.length) { + let i = state._closedWindows.length - 1; + + if (!state._closedWindows[i]._shouldRestore) { + // We only need to go until _shouldRestore + // is falsy since we're going in reverse. + break; + } + + delete state._closedWindows[i]._shouldRestore; + state.windows.unshift(state._closedWindows.pop()); + } + } + + // Clear cookies and storage on clean shutdown. + this._maybeClearCookiesAndStorage(state); + + stopWatchFinish("COLLECT_DATA_MS"); + return this._writeState(state); + }, + + /** + * Purges cookies and DOMSessionStorage data from the session on clean + * shutdown, only if requested by the user's preferences. + */ + _maybeClearCookiesAndStorage(state) { + // Only do this on shutdown. + if (!lazy.RunState.isClosing) { + return; + } + + // Don't clear when restarting. + if ( + Services.prefs.getBoolPref("browser.sessionstore.resume_session_once") + ) { + return; + } + let sanitizeCookies = + Services.prefs.getBoolPref("privacy.sanitize.sanitizeOnShutdown") && + Services.prefs.getBoolPref("privacy.clearOnShutdown.cookies"); + + if (sanitizeCookies) { + // Remove cookies. + delete state.cookies; + + // Remove DOMSessionStorage data. + for (let window of state.windows) { + for (let tab of window.tabs) { + delete tab.storage; + } + } + } + }, + + /** + * Saves the current session state. Collects data asynchronously and calls + * _saveState() to collect data again (with a cache hit rate of hopefully + * 100%) and write to disk afterwards. + */ + _saveStateAsync() { + // Allow scheduling delayed saves again. + this._timeoutID = null; + + // Write to disk. + this._saveState(); + }, + + /** + * Write the given state object to disk. + */ + _writeState(state) { + // We update the time stamp before writing so that we don't write again + // too soon, if saving is requested before the write completes. Without + // this update we may save repeatedly if actions cause a runDelayed + // before writing has completed. See Bug 902280 + this.updateLastSaveTime(); + + // Write (atomically) to a session file, using a tmp file. Once the session + // file is successfully updated, save the time stamp of the last save and + // notify the observers. + return lazy.SessionFile.write(state).then(() => { + this.updateLastSaveTime(); + notify(null, "sessionstore-state-write-complete"); + }, console.error); + }, +}; + +XPCOMUtils.defineLazyPreferenceGetter( + SessionSaverInternal, + "_intervalWhileActive", + PREF_INTERVAL_ACTIVE, + 15000 /* 15 seconds */, + () => { + // Cancel any pending runs and call runDelayed() with + // zero to apply the newly configured interval. + SessionSaverInternal.cancel(); + SessionSaverInternal.runDelayed(0); + } +); + +XPCOMUtils.defineLazyPreferenceGetter( + SessionSaverInternal, + "_intervalWhileIdle", + PREF_INTERVAL_IDLE, + 3600000 /* 1 h */ +); + +XPCOMUtils.defineLazyPreferenceGetter( + SessionSaverInternal, + "_idleDelay", + PREF_IDLE_DELAY, + 180 /* 3 minutes */, + (key, previous, latest) => { + // Update the idle observer for the new `PREF_IDLE_DELAY` value. Here we need + // to re-fetch the service instead of the original one in use; This is for a + // case that the Mock service in the unit test needs to be fetched to + // replace the original one. + var idleService = Cc["@mozilla.org/widget/useridleservice;1"].getService( + Ci.nsIUserIdleService + ); + if (previous != undefined) { + idleService.removeIdleObserver(SessionSaverInternal, previous); + } + if (latest != undefined) { + idleService.addIdleObserver(SessionSaverInternal, latest); + } + } +); + +var idleService = Cc["@mozilla.org/widget/useridleservice;1"].getService( + Ci.nsIUserIdleService +); +idleService.addIdleObserver( + SessionSaverInternal, + SessionSaverInternal._idleDelay +); diff --git a/browser/components/sessionstore/SessionStartup.sys.mjs b/browser/components/sessionstore/SessionStartup.sys.mjs new file mode 100644 index 0000000000..f151989161 --- /dev/null +++ b/browser/components/sessionstore/SessionStartup.sys.mjs @@ -0,0 +1,415 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Session Storage and Restoration + * + * Overview + * This service reads user's session file at startup, and makes a determination + * as to whether the session should be restored. It will restore the session + * under the circumstances described below. If the auto-start Private Browsing + * mode is active, however, the session is never restored. + * + * Crash Detection + * The CrashMonitor is used to check if the final session state was successfully + * written at shutdown of the last session. If we did not reach + * 'sessionstore-final-state-write-complete', then it's assumed that the browser + * has previously crashed and we should restore the session. + * + * Forced Restarts + * In the event that a restart is required due to application update or extension + * installation, set the browser.sessionstore.resume_session_once pref to true, + * and the session will be restored the next time the browser starts. + * + * Always Resume + * This service will always resume the session if the integer pref + * browser.startup.page is set to 3. + */ + +/* :::::::: Constants and Helpers ::::::::::::::: */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + CrashMonitor: "resource://gre/modules/CrashMonitor.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", + SessionFile: "resource:///modules/sessionstore/SessionFile.sys.mjs", + StartupPerformance: + "resource:///modules/sessionstore/StartupPerformance.sys.mjs", +}); + +const STATE_RUNNING_STR = "running"; + +const TYPE_NO_SESSION = 0; +const TYPE_RECOVER_SESSION = 1; +const TYPE_RESUME_SESSION = 2; +const TYPE_DEFER_SESSION = 3; + +// 'browser.startup.page' preference value to resume the previous session. +const BROWSER_STARTUP_RESUME_SESSION = 3; + +function warning(msg, exception) { + let consoleMsg = Cc["@mozilla.org/scripterror;1"].createInstance( + Ci.nsIScriptError + ); + consoleMsg.init( + msg, + exception.fileName, + null, + exception.lineNumber, + 0, + Ci.nsIScriptError.warningFlag, + "component javascript" + ); + Services.console.logMessage(consoleMsg); +} + +var gOnceInitializedDeferred = (function () { + let deferred = {}; + + deferred.promise = new Promise((resolve, reject) => { + deferred.resolve = resolve; + deferred.reject = reject; + }); + + return deferred; +})(); + +/* :::::::: The Service ::::::::::::::: */ + +export var SessionStartup = { + NO_SESSION: TYPE_NO_SESSION, + RECOVER_SESSION: TYPE_RECOVER_SESSION, + RESUME_SESSION: TYPE_RESUME_SESSION, + DEFER_SESSION: TYPE_DEFER_SESSION, + + // The state to restore at startup. + _initialState: null, + _sessionType: null, + _initialized: false, + + // Stores whether the previous session crashed. + _previousSessionCrashed: null, + + _resumeSessionEnabled: null, + + /* ........ Global Event Handlers .............. */ + + /** + * Initialize the component + */ + init() { + Services.obs.notifyObservers(null, "sessionstore-init-started"); + lazy.StartupPerformance.init(); + + // do not need to initialize anything in auto-started private browsing sessions + if (lazy.PrivateBrowsingUtils.permanentPrivateBrowsing) { + this._initialized = true; + gOnceInitializedDeferred.resolve(); + return; + } + + if ( + Services.prefs.getBoolPref( + "browser.sessionstore.resuming_after_os_restart" + ) + ) { + if (!Services.appinfo.restartedByOS) { + // We had set resume_session_once in order to resume after an OS restart, + // but we aren't automatically started by the OS (or else appinfo.restartedByOS + // would have been set). Therefore we should clear resume_session_once + // to avoid forcing a resume for a normal startup. + Services.prefs.setBoolPref( + "browser.sessionstore.resume_session_once", + false + ); + } + Services.prefs.setBoolPref( + "browser.sessionstore.resuming_after_os_restart", + false + ); + } + + lazy.SessionFile.read().then( + this._onSessionFileRead.bind(this), + console.error + ); + }, + + // Wrap a string as a nsISupports. + _createSupportsString(data) { + let string = Cc["@mozilla.org/supports-string;1"].createInstance( + Ci.nsISupportsString + ); + string.data = data; + return string; + }, + + /** + * Complete initialization once the Session File has been read. + * + * @param source The Session State string read from disk. + * @param parsed The object obtained by parsing |source| as JSON. + */ + _onSessionFileRead({ source, parsed, noFilesFound }) { + this._initialized = true; + + // Let observers modify the state before it is used + let supportsStateString = this._createSupportsString(source); + Services.obs.notifyObservers( + supportsStateString, + "sessionstore-state-read" + ); + let stateString = supportsStateString.data; + + if (stateString != source) { + // The session has been modified by an add-on, reparse. + try { + this._initialState = JSON.parse(stateString); + } catch (ex) { + // That's not very good, an add-on has rewritten the initial + // state to something that won't parse. + warning("Observer rewrote the state to something that won't parse", ex); + } + } else { + // No need to reparse + this._initialState = parsed; + } + + if (this._initialState == null) { + // No valid session found. + this._sessionType = this.NO_SESSION; + Services.obs.notifyObservers(null, "sessionstore-state-finalized"); + gOnceInitializedDeferred.resolve(); + return; + } + + let initialState = this._initialState; + Services.tm.idleDispatchToMainThread(() => { + let pinnedTabCount = initialState.windows.reduce((winAcc, win) => { + return ( + winAcc + + win.tabs.reduce((tabAcc, tab) => { + return tabAcc + (tab.pinned ? 1 : 0); + }, 0) + ); + }, 0); + Services.telemetry.scalarSetMaximum( + "browser.engagement.max_concurrent_tab_pinned_count", + pinnedTabCount + ); + }, 60000); + + // If this is a normal restore then throw away any previous session. + if (!this.isAutomaticRestoreEnabled() && this._initialState) { + delete this._initialState.lastSessionState; + } + + lazy.CrashMonitor.previousCheckpoints.then(checkpoints => { + if (checkpoints) { + // If the previous session finished writing the final state, we'll + // assume there was no crash. + this._previousSessionCrashed = + !checkpoints["sessionstore-final-state-write-complete"]; + } else if (noFilesFound) { + // If the Crash Monitor could not load a checkpoints file it will + // provide null. This could occur on the first run after updating to + // a version including the Crash Monitor, or if the checkpoints file + // was removed, or on first startup with this profile, or after Firefox Reset. + + // There was no checkpoints file and no sessionstore.js or its backups, + // so we will assume that this was a fresh profile. + this._previousSessionCrashed = false; + } else { + // If this is the first run after an update, sessionstore.js should + // still contain the session.state flag to indicate if the session + // crashed. If it is not present, we will assume this was not the first + // run after update and the checkpoints file was somehow corrupted or + // removed by a crash. + // + // If the session.state flag is present, we will fallback to using it + // for crash detection - If the last write of sessionstore.js had it + // set to "running", we crashed. + let stateFlagPresent = + this._initialState.session && this._initialState.session.state; + + this._previousSessionCrashed = + !stateFlagPresent || + this._initialState.session.state == STATE_RUNNING_STR; + } + + // Report shutdown success via telemetry. Shortcoming here are + // being-killed-by-OS-shutdown-logic, shutdown freezing after + // session restore was written, etc. + Services.telemetry + .getHistogramById("SHUTDOWN_OK") + .add(!this._previousSessionCrashed); + + Services.obs.addObserver(this, "sessionstore-windows-restored", true); + + if (this.sessionType == this.NO_SESSION) { + this._initialState = null; // Reset the state. + } else { + Services.obs.addObserver(this, "browser:purge-session-history", true); + } + + // We're ready. Notify everyone else. + Services.obs.notifyObservers(null, "sessionstore-state-finalized"); + + gOnceInitializedDeferred.resolve(); + }); + }, + + /** + * Handle notifications + */ + observe(subject, topic, data) { + switch (topic) { + case "sessionstore-windows-restored": + Services.obs.removeObserver(this, "sessionstore-windows-restored"); + // Free _initialState after nsSessionStore is done with it. + this._initialState = null; + this._didRestore = true; + break; + case "browser:purge-session-history": + Services.obs.removeObserver(this, "browser:purge-session-history"); + // Reset all state on sanitization. + this._sessionType = this.NO_SESSION; + break; + } + }, + + /* ........ Public API ................*/ + + get onceInitialized() { + return gOnceInitializedDeferred.promise; + }, + + /** + * Get the session state as a jsval + */ + get state() { + return this._initialState; + }, + + /** + * Determines whether automatic session restoration is enabled for this + * launch of the browser. This does not include crash restoration. In + * particular, if session restore is configured to restore only in case of + * crash, this method returns false. + * @returns bool + */ + isAutomaticRestoreEnabled() { + if (this._resumeSessionEnabled === null) { + this._resumeSessionEnabled = + !lazy.PrivateBrowsingUtils.permanentPrivateBrowsing && + (Services.prefs.getBoolPref( + "browser.sessionstore.resume_session_once" + ) || + Services.prefs.getIntPref("browser.startup.page") == + BROWSER_STARTUP_RESUME_SESSION); + } + + return this._resumeSessionEnabled; + }, + + /** + * Determines whether there is a pending session restore. + * @returns bool + */ + willRestore() { + return ( + this.sessionType == this.RECOVER_SESSION || + this.sessionType == this.RESUME_SESSION + ); + }, + + /** + * Determines whether there is a pending session restore and if that will refer + * back to a crash. + * @returns bool + */ + willRestoreAsCrashed() { + return this.sessionType == this.RECOVER_SESSION; + }, + + /** + * Returns a boolean or a promise that resolves to a boolean, indicating + * whether we will restore a session that ends up replacing the homepage. + * True guarantees that we'll restore a session; false means that we + * /probably/ won't do so. + * The browser uses this to avoid unnecessarily loading the homepage when + * restoring a session. + */ + get willOverrideHomepage() { + // If the session file hasn't been read yet and resuming the session isn't + // enabled via prefs, go ahead and load the homepage. We may still replace + // it when recovering from a crash, which we'll only know after reading the + // session file, but waiting for that would delay loading the homepage in + // the non-crash case. + if (!this._initialState && !this.isAutomaticRestoreEnabled()) { + return false; + } + // If we've already restored the session, we won't override again. + if (this._didRestore) { + return false; + } + + return new Promise(resolve => { + this.onceInitialized.then(() => { + // If there are valid windows with not only pinned tabs, signal that we + // will override the default homepage by restoring a session. + resolve( + this.willRestore() && + this._initialState && + this._initialState.windows && + (!this.willRestoreAsCrashed() + ? this._initialState.windows.filter(w => !w._maybeDontRestoreTabs) + : this._initialState.windows + ).some(w => w.tabs.some(t => !t.pinned)) + ); + }); + }); + }, + + /** + * Get the type of pending session store, if any. + */ + get sessionType() { + if (this._sessionType === null) { + let resumeFromCrash = Services.prefs.getBoolPref( + "browser.sessionstore.resume_from_crash" + ); + // Set the startup type. + if (this.isAutomaticRestoreEnabled()) { + this._sessionType = this.RESUME_SESSION; + } else if (this._previousSessionCrashed && resumeFromCrash) { + this._sessionType = this.RECOVER_SESSION; + } else if (this._initialState) { + this._sessionType = this.DEFER_SESSION; + } else { + this._sessionType = this.NO_SESSION; + } + } + + return this._sessionType; + }, + + /** + * Get whether the previous session crashed. + */ + get previousSessionCrashed() { + return this._previousSessionCrashed; + }, + + resetForTest() { + this._resumeSessionEnabled = null; + this._sessionType = null; + }, + + QueryInterface: ChromeUtils.generateQI([ + "nsIObserver", + "nsISupportsWeakReference", + ]), +}; diff --git a/browser/components/sessionstore/SessionStore.sys.mjs b/browser/components/sessionstore/SessionStore.sys.mjs new file mode 100644 index 0000000000..602986e529 --- /dev/null +++ b/browser/components/sessionstore/SessionStore.sys.mjs @@ -0,0 +1,6932 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// Current version of the format used by Session Restore. +const FORMAT_VERSION = 1; + +const TAB_CUSTOM_VALUES = new WeakMap(); +const TAB_LAZY_STATES = new WeakMap(); +const TAB_STATE_NEEDS_RESTORE = 1; +const TAB_STATE_RESTORING = 2; +const TAB_STATE_FOR_BROWSER = new WeakMap(); +const WINDOW_RESTORE_IDS = new WeakMap(); +const WINDOW_RESTORE_ZINDICES = new WeakMap(); +const WINDOW_SHOWING_PROMISES = new Map(); +const WINDOW_FLUSHING_PROMISES = new Map(); + +// A new window has just been restored. At this stage, tabs are generally +// not restored. +const NOTIFY_SINGLE_WINDOW_RESTORED = "sessionstore-single-window-restored"; +const NOTIFY_WINDOWS_RESTORED = "sessionstore-windows-restored"; +const NOTIFY_BROWSER_STATE_RESTORED = "sessionstore-browser-state-restored"; +const NOTIFY_LAST_SESSION_CLEARED = "sessionstore-last-session-cleared"; +const NOTIFY_RESTORING_ON_STARTUP = "sessionstore-restoring-on-startup"; +const NOTIFY_INITIATING_MANUAL_RESTORE = + "sessionstore-initiating-manual-restore"; +const NOTIFY_CLOSED_OBJECTS_CHANGED = "sessionstore-closed-objects-changed"; + +const NOTIFY_TAB_RESTORED = "sessionstore-debug-tab-restored"; // WARNING: debug-only +const NOTIFY_DOMWINDOWCLOSED_HANDLED = + "sessionstore-debug-domwindowclosed-handled"; // WARNING: debug-only + +const NOTIFY_BROWSER_SHUTDOWN_FLUSH = "sessionstore-browser-shutdown-flush"; + +// Maximum number of tabs to restore simultaneously. Previously controlled by +// the browser.sessionstore.max_concurrent_tabs pref. +const MAX_CONCURRENT_TAB_RESTORES = 3; + +// Minimum amount (in CSS px) by which we allow window edges to be off-screen +// when restoring a window, before we override the saved position to pull the +// window back within the available screen area. +const MIN_SCREEN_EDGE_SLOP = 8; + +// global notifications observed +const OBSERVING = [ + "browser-window-before-show", + "domwindowclosed", + "quit-application-granted", + "browser-lastwindow-close-granted", + "quit-application", + "browser:purge-session-history", + "browser:purge-session-history-for-domain", + "idle-daily", + "clear-origin-attributes-data", + "browsing-context-did-set-embedder", + "browsing-context-discarded", + "browser-shutdown-tabstate-updated", +]; + +// XUL Window properties to (re)store +// Restored in restoreDimensions() +const WINDOW_ATTRIBUTES = ["width", "height", "screenX", "screenY", "sizemode"]; + +const CHROME_FLAGS_MAP = [ + [Ci.nsIWebBrowserChrome.CHROME_TITLEBAR, "titlebar"], + [Ci.nsIWebBrowserChrome.CHROME_WINDOW_CLOSE, "close"], + [Ci.nsIWebBrowserChrome.CHROME_TOOLBAR, "toolbar"], + [Ci.nsIWebBrowserChrome.CHROME_LOCATIONBAR, "location"], + [Ci.nsIWebBrowserChrome.CHROME_PERSONAL_TOOLBAR, "personalbar"], + [Ci.nsIWebBrowserChrome.CHROME_STATUSBAR, "status"], + [Ci.nsIWebBrowserChrome.CHROME_MENUBAR, "menubar"], + [Ci.nsIWebBrowserChrome.CHROME_WINDOW_RESIZE, "resizable"], + [Ci.nsIWebBrowserChrome.CHROME_WINDOW_MIN, "minimizable"], + [Ci.nsIWebBrowserChrome.CHROME_SCROLLBARS, "", "scrollbars=0"], + [Ci.nsIWebBrowserChrome.CHROME_PRIVATE_WINDOW, "private"], + [Ci.nsIWebBrowserChrome.CHROME_NON_PRIVATE_WINDOW, "non-private"], + // Do not inherit remoteness and fissionness from the previous session. + //[Ci.nsIWebBrowserChrome.CHROME_REMOTE_WINDOW, "remote", "non-remote"], + //[Ci.nsIWebBrowserChrome.CHROME_FISSION_WINDOW, "fission", "non-fission"], + [Ci.nsIWebBrowserChrome.CHROME_WINDOW_POPUP, "popup"], + [ + Ci.nsIWebBrowserChrome.CHROME_WINDOW_POPUP | + Ci.nsIWebBrowserChrome.CHROME_TITLEBAR, + "", + "titlebar=0", + ], + [ + Ci.nsIWebBrowserChrome.CHROME_WINDOW_POPUP | + Ci.nsIWebBrowserChrome.CHROME_WINDOW_CLOSE, + "", + "close=0", + ], + [Ci.nsIWebBrowserChrome.CHROME_WINDOW_LOWERED, "alwayslowered"], + [Ci.nsIWebBrowserChrome.CHROME_WINDOW_RAISED, "alwaysraised"], + // "chrome" and "suppressanimation" are always set. + //[Ci.nsIWebBrowserChrome.CHROME_SUPPRESS_ANIMATION, "suppressanimation"], + [Ci.nsIWebBrowserChrome.CHROME_ALWAYS_ON_TOP, "alwaysontop"], + //[Ci.nsIWebBrowserChrome.CHROME_OPENAS_CHROME, "chrome", "chrome=0"], + [Ci.nsIWebBrowserChrome.CHROME_EXTRA, "extrachrome"], + [Ci.nsIWebBrowserChrome.CHROME_CENTER_SCREEN, "centerscreen"], + [Ci.nsIWebBrowserChrome.CHROME_DEPENDENT, "dependent"], + [Ci.nsIWebBrowserChrome.CHROME_MODAL, "modal"], + [Ci.nsIWebBrowserChrome.CHROME_OPENAS_DIALOG, "dialog", "dialog=0"], +]; + +// Hideable window features to (re)store +// Restored in restoreWindowFeatures() +const WINDOW_HIDEABLE_FEATURES = [ + "menubar", + "toolbar", + "locationbar", + "personalbar", + "statusbar", + "scrollbars", +]; + +const WINDOW_OPEN_FEATURES_MAP = { + locationbar: "location", + statusbar: "status", +}; + +// Messages that will be received via the Frame Message Manager. +const MESSAGES = [ + // The content script sends us data that has been invalidated and needs to + // be saved to disk. + "SessionStore:update", + + // The restoreHistory code has run. This is a good time to run SSTabRestoring. + "SessionStore:restoreHistoryComplete", + + // The load for the restoring tab has begun. We update the URL bar at this + // time; if we did it before, the load would overwrite it. + "SessionStore:restoreTabContentStarted", + + // All network loads for a restoring tab are done, so we should + // consider restoring another tab in the queue. The document has + // been restored, and forms have been filled. We trigger + // SSTabRestored at this time. + "SessionStore:restoreTabContentComplete", + + // The content script encountered an error. + "SessionStore:error", +]; + +// The list of messages we accept from s that have no tab +// assigned, or whose windows have gone away. Those are for example the +// ones that preload about:newtab pages, or from browsers where the window +// has just been closed. +const NOTAB_MESSAGES = new Set([ + // For a description see above. + "SessionStore:update", + + // For a description see above. + "SessionStore:error", +]); + +// The list of messages we accept without an "epoch" parameter. +// See getCurrentEpoch() and friends to find out what an "epoch" is. +const NOEPOCH_MESSAGES = new Set([ + // For a description see above. + "SessionStore:error", +]); + +// The list of messages we want to receive even during the short period after a +// frame has been removed from the DOM and before its frame script has finished +// unloading. +const CLOSED_MESSAGES = new Set([ + // For a description see above. + "SessionStore:update", + + // For a description see above. + "SessionStore:error", +]); + +// These are tab events that we listen to. +const TAB_EVENTS = [ + "TabOpen", + "TabBrowserInserted", + "TabClose", + "TabSelect", + "TabShow", + "TabHide", + "TabPinned", + "TabUnpinned", +]; + +const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; + +/** + * When calling restoreTabContent, we can supply a reason why + * the content is being restored. These are those reasons. + */ +const RESTORE_TAB_CONTENT_REASON = { + /** + * SET_STATE: + * We're restoring this tab's content because we're setting + * state inside this browser tab, probably because the user + * has asked us to restore a tab (or window, or entire session). + */ + SET_STATE: 0, + /** + * NAVIGATE_AND_RESTORE: + * We're restoring this tab's content because a navigation caused + * us to do a remoteness-flip. + */ + NAVIGATE_AND_RESTORE: 1, +}; + +// 'browser.startup.page' preference value to resume the previous session. +const BROWSER_STARTUP_RESUME_SESSION = 3; + +// Used by SessionHistoryListener. +const kNoIndex = Number.MAX_SAFE_INTEGER; +const kLastIndex = Number.MAX_SAFE_INTEGER - 1; + +import { PrivateBrowsingUtils } from "resource://gre/modules/PrivateBrowsingUtils.sys.mjs"; + +import { TelemetryTimestamps } from "resource://gre/modules/TelemetryTimestamps.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; +import { GlobalState } from "resource:///modules/sessionstore/GlobalState.sys.mjs"; + +const lazy = {}; + +XPCOMUtils.defineLazyServiceGetters(lazy, { + gScreenManager: ["@mozilla.org/gfx/screenmanager;1", "nsIScreenManager"], +}); + +ChromeUtils.defineESModuleGetters(lazy, { + AsyncShutdown: "resource://gre/modules/AsyncShutdown.sys.mjs", + DevToolsShim: "chrome://devtools-startup/content/DevToolsShim.sys.mjs", + E10SUtils: "resource://gre/modules/E10SUtils.sys.mjs", + PrivacyFilter: "resource://gre/modules/sessionstore/PrivacyFilter.sys.mjs", + PromiseUtils: "resource://gre/modules/PromiseUtils.sys.mjs", + RunState: "resource:///modules/sessionstore/RunState.sys.mjs", + SessionCookies: "resource:///modules/sessionstore/SessionCookies.sys.mjs", + SessionFile: "resource:///modules/sessionstore/SessionFile.sys.mjs", + SessionHistory: "resource://gre/modules/sessionstore/SessionHistory.sys.mjs", + SessionSaver: "resource:///modules/sessionstore/SessionSaver.sys.mjs", + SessionStartup: "resource:///modules/sessionstore/SessionStartup.sys.mjs", + TabAttributes: "resource:///modules/sessionstore/TabAttributes.sys.mjs", + TabState: "resource:///modules/sessionstore/TabState.sys.mjs", + TabStateCache: "resource:///modules/sessionstore/TabStateCache.sys.mjs", + TabStateFlusher: "resource:///modules/sessionstore/TabStateFlusher.sys.mjs", + setTimeout: "resource://gre/modules/Timer.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(lazy, { + BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.jsm", + HomePage: "resource:///modules/HomePage.jsm", + TabCrashHandler: "resource:///modules/ContentCrashHandlers.jsm", +}); + +XPCOMUtils.defineLazyGetter(lazy, "blankURI", () => { + return Services.io.newURI("about:blank"); +}); + +/** + * |true| if we are in debug mode, |false| otherwise. + * Debug mode is controlled by preference browser.sessionstore.debug + */ +var gDebuggingEnabled = false; + +/** + * A global value to tell that fingerprinting resistance is enabled or not. + * If it's enabled, the session restore won't restore the window's size and + * size mode. + * This value is controlled by preference privacy.resistFingerprinting. + */ +var gResistFingerprintingEnabled = false; + +/** + * @namespace SessionStore + */ +export var SessionStore = { + get promiseInitialized() { + return SessionStoreInternal.promiseInitialized; + }, + + get promiseAllWindowsRestored() { + return SessionStoreInternal.promiseAllWindowsRestored; + }, + + get canRestoreLastSession() { + return SessionStoreInternal.canRestoreLastSession; + }, + + set canRestoreLastSession(val) { + SessionStoreInternal.canRestoreLastSession = val; + }, + + get lastClosedObjectType() { + return SessionStoreInternal.lastClosedObjectType; + }, + + get willAutoRestore() { + return SessionStoreInternal.willAutoRestore; + }, + + init: function ss_init() { + SessionStoreInternal.init(); + }, + + getBrowserState: function ss_getBrowserState() { + return SessionStoreInternal.getBrowserState(); + }, + + setBrowserState: function ss_setBrowserState(aState) { + SessionStoreInternal.setBrowserState(aState); + }, + + getWindowState: function ss_getWindowState(aWindow) { + return SessionStoreInternal.getWindowState(aWindow); + }, + + setWindowState: function ss_setWindowState(aWindow, aState, aOverwrite) { + SessionStoreInternal.setWindowState(aWindow, aState, aOverwrite); + }, + + getTabState: function ss_getTabState(aTab) { + return SessionStoreInternal.getTabState(aTab); + }, + + setTabState: function ss_setTabState(aTab, aState) { + SessionStoreInternal.setTabState(aTab, aState); + }, + + // Return whether a tab is restoring. + isTabRestoring(aTab) { + return TAB_STATE_FOR_BROWSER.has(aTab.linkedBrowser); + }, + + getInternalObjectState(obj) { + return SessionStoreInternal.getInternalObjectState(obj); + }, + + duplicateTab: function ss_duplicateTab( + aWindow, + aTab, + aDelta = 0, + aRestoreImmediately = true, + aOptions = {} + ) { + return SessionStoreInternal.duplicateTab( + aWindow, + aTab, + aDelta, + aRestoreImmediately, + aOptions + ); + }, + + getLastClosedTabCount(aWindow) { + return SessionStoreInternal.getLastClosedTabCount(aWindow); + }, + + resetLastClosedTabCount(aWindow) { + SessionStoreInternal.resetLastClosedTabCount(aWindow); + }, + + getClosedTabCountForWindow: function ss_getClosedTabCountForWindow(aWindow) { + return SessionStoreInternal.getClosedTabCountForWindow(aWindow); + }, + + getClosedTabDataForWindow: function ss_getClosedTabDataForWindow(aWindow) { + return SessionStoreInternal.getClosedTabDataForWindow(aWindow); + }, + + undoCloseTab: function ss_undoCloseTab(aWindow, aIndex) { + return SessionStoreInternal.undoCloseTab(aWindow, aIndex); + }, + + forgetClosedTab: function ss_forgetClosedTab(aWindow, aIndex) { + return SessionStoreInternal.forgetClosedTab(aWindow, aIndex); + }, + + getClosedWindowCount: function ss_getClosedWindowCount() { + return SessionStoreInternal.getClosedWindowCount(); + }, + + getClosedWindowData: function ss_getClosedWindowData() { + return SessionStoreInternal.getClosedWindowData(); + }, + + maybeDontRestoreTabs(aWindow) { + SessionStoreInternal.maybeDontRestoreTabs(aWindow); + }, + + undoCloseWindow: function ss_undoCloseWindow(aIndex) { + return SessionStoreInternal.undoCloseWindow(aIndex); + }, + + forgetClosedWindow: function ss_forgetClosedWindow(aIndex) { + return SessionStoreInternal.forgetClosedWindow(aIndex); + }, + + getCustomWindowValue(aWindow, aKey) { + return SessionStoreInternal.getCustomWindowValue(aWindow, aKey); + }, + + setCustomWindowValue(aWindow, aKey, aStringValue) { + SessionStoreInternal.setCustomWindowValue(aWindow, aKey, aStringValue); + }, + + deleteCustomWindowValue(aWindow, aKey) { + SessionStoreInternal.deleteCustomWindowValue(aWindow, aKey); + }, + + getCustomTabValue(aTab, aKey) { + return SessionStoreInternal.getCustomTabValue(aTab, aKey); + }, + + setCustomTabValue(aTab, aKey, aStringValue) { + SessionStoreInternal.setCustomTabValue(aTab, aKey, aStringValue); + }, + + deleteCustomTabValue(aTab, aKey) { + SessionStoreInternal.deleteCustomTabValue(aTab, aKey); + }, + + getLazyTabValue(aTab, aKey) { + return SessionStoreInternal.getLazyTabValue(aTab, aKey); + }, + + getCustomGlobalValue(aKey) { + return SessionStoreInternal.getCustomGlobalValue(aKey); + }, + + setCustomGlobalValue(aKey, aStringValue) { + SessionStoreInternal.setCustomGlobalValue(aKey, aStringValue); + }, + + deleteCustomGlobalValue(aKey) { + SessionStoreInternal.deleteCustomGlobalValue(aKey); + }, + + persistTabAttribute: function ss_persistTabAttribute(aName) { + SessionStoreInternal.persistTabAttribute(aName); + }, + + restoreLastSession: function ss_restoreLastSession() { + SessionStoreInternal.restoreLastSession(); + }, + + speculativeConnectOnTabHover(tab) { + SessionStoreInternal.speculativeConnectOnTabHover(tab); + }, + + getCurrentState(aUpdateAll) { + return SessionStoreInternal.getCurrentState(aUpdateAll); + }, + + reviveCrashedTab(aTab) { + return SessionStoreInternal.reviveCrashedTab(aTab); + }, + + reviveAllCrashedTabs() { + return SessionStoreInternal.reviveAllCrashedTabs(); + }, + + updateSessionStoreFromTablistener( + aBrowser, + aBrowsingContext, + aPermanentKey, + aData, + aForStorage + ) { + return SessionStoreInternal.updateSessionStoreFromTablistener( + aBrowser, + aBrowsingContext, + aPermanentKey, + aData, + aForStorage + ); + }, + + getSessionHistory(tab, updatedCallback) { + return SessionStoreInternal.getSessionHistory(tab, updatedCallback); + }, + + undoCloseById(aClosedId, aIncludePrivate) { + return SessionStoreInternal.undoCloseById(aClosedId, aIncludePrivate); + }, + + resetBrowserToLazyState(tab) { + return SessionStoreInternal.resetBrowserToLazyState(tab); + }, + + maybeExitCrashedState(browser) { + SessionStoreInternal.maybeExitCrashedState(browser); + }, + + isBrowserInCrashedSet(browser) { + return SessionStoreInternal.isBrowserInCrashedSet(browser); + }, + + // this is used for testing purposes + resetNextClosedId() { + SessionStoreInternal._nextClosedId = 0; + }, + + /** + * Ensures that session store has registered and started tracking a given window. + * @param window + * Window reference + */ + ensureInitialized(window) { + if (SessionStoreInternal._sessionInitialized && !window.__SSi) { + /* + We need to check that __SSi is not defined on the window so that if + onLoad function is in the middle of executing we don't enter the function + again and try to redeclare the ContentSessionStore script. + */ + SessionStoreInternal.onLoad(window); + } + }, + + getCurrentEpoch(browser) { + return SessionStoreInternal.getCurrentEpoch(browser.permanentKey); + }, + + /** + * Determines whether the passed version number is compatible with + * the current version number of the SessionStore. + * + * @param version The format and version of the file, as an array, e.g. + * ["sessionrestore", 1] + */ + isFormatVersionCompatible(version) { + if (!version) { + return false; + } + if (!Array.isArray(version)) { + // Improper format. + return false; + } + if (version[0] != "sessionrestore") { + // Not a Session Restore file. + return false; + } + let number = Number.parseFloat(version[1]); + if (Number.isNaN(number)) { + return false; + } + return number <= FORMAT_VERSION; + }, + + /** + * Filters out not worth-saving tabs from a given browser state object. + * + * @param aState (object) + * The browser state for which we remove worth-saving tabs. + * The given object will be modified. + */ + keepOnlyWorthSavingTabs(aState) { + let closedWindowShouldRestore = null; + for (let i = aState.windows.length - 1; i >= 0; i--) { + let win = aState.windows[i]; + for (let j = win.tabs.length - 1; j >= 0; j--) { + let tab = win.tabs[j]; + if (!SessionStoreInternal._shouldSaveTab(tab)) { + win.tabs.splice(j, 1); + if (win.selected > j) { + win.selected--; + } + } + } + + // If it's the last window (and no closedWindow that will restore), keep the window state with no tabs. + if ( + !win.tabs.length && + (aState.windows.length > 1 || + closedWindowShouldRestore || + (closedWindowShouldRestore == null && + (closedWindowShouldRestore = aState._closedWindows.some( + w => w._shouldRestore + )))) + ) { + aState.windows.splice(i, 1); + if (aState.selectedWindow > i) { + aState.selectedWindow--; + } + } + } + }, + + /** + * Prepares to change the remoteness of the given browser, by ensuring that + * the local instance of session history is up-to-date. + */ + async prepareToChangeRemoteness(aTab) { + await SessionStoreInternal.prepareToChangeRemoteness(aTab); + }, + + finishTabRemotenessChange(aTab, aSwitchId) { + SessionStoreInternal.finishTabRemotenessChange(aTab, aSwitchId); + }, +}; + +// Freeze the SessionStore object. We don't want anyone to modify it. +Object.freeze(SessionStore); + +/** + * @namespace SessionStoreInternal + * + * @description Internal implementations and helpers for the public SessionStore methods + */ +var SessionStoreInternal = { + QueryInterface: ChromeUtils.generateQI([ + "nsIObserver", + "nsISupportsWeakReference", + ]), + + _globalState: new GlobalState(), + + // A counter to be used to generate a unique ID for each closed tab or window. + _nextClosedId: 0, + + // During the initial restore and setBrowserState calls tracks the number of + // windows yet to be restored + _restoreCount: -1, + + // For each element, records the SHistoryListener. + _browserSHistoryListener: new WeakMap(), + + // Tracks the various listeners that are used throughout the restore. + _restoreListeners: new WeakMap(), + + // Records the promise created in _restoreHistory, which is used to track + // the completion of the first phase of the restore. + _tabStateRestorePromises: new WeakMap(), + + // The history data needed to be restored in the parent. + _tabStateToRestore: new WeakMap(), + + // For each element, records the current epoch. + _browserEpochs: new WeakMap(), + + // Any browsers that fires the oop-browser-crashed event gets stored in + // here - that way we know which browsers to ignore messages from (until + // they get restored). + _crashedBrowsers: new WeakSet(), + + // A map (xul:browser -> FrameLoader) that maps a browser to the last + // associated frameLoader we heard about. + _lastKnownFrameLoader: new WeakMap(), + + // A map (xul:browser -> object) that maps a browser associated with a + // recently closed tab to all its necessary state information we need to + // properly handle final update message. + _closedTabs: new WeakMap(), + + // A map (xul:browser -> object) that maps a browser associated with a + // recently closed tab due to a window closure to the tab state information + // that is being stored in _closedWindows for that tab. + _closedWindowTabs: new WeakMap(), + + // A set of window data that has the potential to be saved in the _closedWindows + // array for the session. We will remove window data from this set whenever + // forgetClosedWindow is called for the window, or when session history is + // purged, so that we don't accidentally save that data after the flush has + // completed. Closed tabs use a more complicated mechanism for this particular + // problem. When forgetClosedTab is called, the browser is removed from the + // _closedTabs map, so its data is not recorded. In the purge history case, + // the closedTabs array per window is overwritten so that once the flush is + // complete, the tab would only ever add itself to an array that SessionStore + // no longer cares about. Bug 1230636 has been filed to make the tab case + // work more like the window case, which is more explicit, and easier to + // reason about. + _saveableClosedWindowData: new WeakSet(), + + // whether a setBrowserState call is in progress + _browserSetState: false, + + // time in milliseconds when the session was started (saved across sessions), + // defaults to now if no session was restored or timestamp doesn't exist + _sessionStartTime: Date.now(), + + // states for all currently opened windows + _windows: {}, + + // counter for creating unique window IDs + _nextWindowID: 0, + + // states for all recently closed windows + _closedWindows: [], + + // collection of session states yet to be restored + _statesToRestore: {}, + + // counts the number of crashes since the last clean start + _recentCrashes: 0, + + // whether the last window was closed and should be restored + _restoreLastWindow: false, + + // number of tabs currently restoring + _tabsRestoringCount: 0, + + _log: null, + + // When starting Firefox with a single private window, this is the place + // where we keep the session we actually wanted to restore in case the user + // decides to later open a non-private window as well. + _deferredInitialState: null, + + // Keeps track of whether a notification needs to be sent that closed objects have changed. + _closedObjectsChanged: false, + + // A promise resolved once initialization is complete + _deferredInitialized: (function () { + let deferred = {}; + + deferred.promise = new Promise((resolve, reject) => { + deferred.resolve = resolve; + deferred.reject = reject; + }); + + return deferred; + })(), + + // Whether session has been initialized + _sessionInitialized: false, + + // A promise resolved once all windows are restored. + _deferredAllWindowsRestored: (function () { + let deferred = {}; + + deferred.promise = new Promise((resolve, reject) => { + deferred.resolve = resolve; + deferred.reject = reject; + }); + + return deferred; + })(), + + get promiseAllWindowsRestored() { + return this._deferredAllWindowsRestored.promise; + }, + + // Promise that is resolved when we're ready to initialize + // and restore the session. + _promiseReadyForInitialization: null, + + // Keep busy state counters per window. + _windowBusyStates: new WeakMap(), + + /** + * A promise fulfilled once initialization is complete. + */ + get promiseInitialized() { + return this._deferredInitialized.promise; + }, + + get canRestoreLastSession() { + return LastSession.canRestore; + }, + + set canRestoreLastSession(val) { + // Cheat a bit; only allow false. + if (!val) { + LastSession.clear(); + } + }, + + /** + * Returns a string describing the last closed object, either "tab" or "window". + * + * This was added to support the sessions.restore WebExtensions API. + */ + get lastClosedObjectType() { + if (this._closedWindows.length) { + // Since there are closed windows, we need to check if there's a closed tab + // in one of the currently open windows that was closed after the + // last-closed window. + let tabTimestamps = []; + for (let window of Services.wm.getEnumerator("navigator:browser")) { + let windowState = this._windows[window.__SSi]; + if (windowState && windowState._closedTabs[0]) { + tabTimestamps.push(windowState._closedTabs[0].closedAt); + } + } + if ( + !tabTimestamps.length || + tabTimestamps.sort((a, b) => b - a)[0] < this._closedWindows[0].closedAt + ) { + return "window"; + } + } + return "tab"; + }, + + /** + * Returns a boolean that determines whether the session will be automatically + * restored upon the _next_ startup or a restart. + */ + get willAutoRestore() { + return ( + !PrivateBrowsingUtils.permanentPrivateBrowsing && + (Services.prefs.getBoolPref("browser.sessionstore.resume_session_once") || + Services.prefs.getIntPref("browser.startup.page") == + BROWSER_STARTUP_RESUME_SESSION) + ); + }, + + /** + * Initialize the sessionstore service. + */ + init() { + if (this._initialized) { + throw new Error("SessionStore.init() must only be called once!"); + } + + TelemetryTimestamps.add("sessionRestoreInitialized"); + OBSERVING.forEach(function (aTopic) { + Services.obs.addObserver(this, aTopic, true); + }, this); + + this._initPrefs(); + this._initialized = true; + + Services.telemetry + .getHistogramById("FX_SESSION_RESTORE_PRIVACY_LEVEL") + .add(Services.prefs.getIntPref("browser.sessionstore.privacy_level")); + }, + + /** + * Initialize the session using the state provided by SessionStartup + */ + initSession() { + TelemetryStopwatch.start("FX_SESSION_RESTORE_STARTUP_INIT_SESSION_MS"); + let state; + let ss = lazy.SessionStartup; + + if (ss.willRestore() || ss.sessionType == ss.DEFER_SESSION) { + state = ss.state; + } + + if (state) { + try { + // If we're doing a DEFERRED session, then we want to pull pinned tabs + // out so they can be restored. + if (ss.sessionType == ss.DEFER_SESSION) { + let [iniState, remainingState] = + this._prepDataForDeferredRestore(state); + // If we have a iniState with windows, that means that we have windows + // with app tabs to restore. + if (iniState.windows.length) { + // Move cookies over from the remaining state so that they're + // restored right away, and pinned tabs will load correctly. + iniState.cookies = remainingState.cookies; + delete remainingState.cookies; + state = iniState; + } else { + state = null; + } + + if (remainingState.windows.length) { + LastSession.setState(remainingState); + } + Services.telemetry.keyedScalarAdd( + "browser.engagement.sessionrestore_interstitial", + "deferred_restore", + 1 + ); + } else { + // Get the last deferred session in case the user still wants to + // restore it + LastSession.setState(state.lastSessionState); + + let restoreAsCrashed = ss.willRestoreAsCrashed(); + if (restoreAsCrashed) { + this._recentCrashes = + ((state.session && state.session.recentCrashes) || 0) + 1; + + // _needsRestorePage will record sessionrestore_interstitial, + // including the specific reason we decided we needed to show + // about:sessionrestore, if that's what we do. + if (this._needsRestorePage(state, this._recentCrashes)) { + // replace the crashed session with a restore-page-only session + let url = "about:sessionrestore"; + let formdata = { id: { sessionData: state }, url }; + let entry = { + url, + triggeringPrincipal_base64: + lazy.E10SUtils.SERIALIZED_SYSTEMPRINCIPAL, + }; + state = { windows: [{ tabs: [{ entries: [entry], formdata }] }] }; + } else if ( + this._hasSingleTabWithURL(state.windows, "about:welcomeback") + ) { + Services.telemetry.keyedScalarAdd( + "browser.engagement.sessionrestore_interstitial", + "shown_only_about_welcomeback", + 1 + ); + // On a single about:welcomeback URL that crashed, replace about:welcomeback + // with about:sessionrestore, to make clear to the user that we crashed. + state.windows[0].tabs[0].entries[0].url = "about:sessionrestore"; + state.windows[0].tabs[0].entries[0].triggeringPrincipal_base64 = + lazy.E10SUtils.SERIALIZED_SYSTEMPRINCIPAL; + } else { + restoreAsCrashed = false; + } + } + + // If we didn't use about:sessionrestore, record that: + if (!restoreAsCrashed) { + Services.telemetry.keyedScalarAdd( + "browser.engagement.sessionrestore_interstitial", + "autorestore", + 1 + ); + + this._removeExplicitlyClosedTabs(state); + } + + // Update the session start time using the restored session state. + this._updateSessionStartTime(state); + + // Make sure that at least the first window doesn't have anything hidden. + delete state.windows[0].hidden; + // Since nothing is hidden in the first window, it cannot be a popup. + delete state.windows[0].isPopup; + // We don't want to minimize and then open a window at startup. + if (state.windows[0].sizemode == "minimized") { + state.windows[0].sizemode = "normal"; + } + + // clear any lastSessionWindowID attributes since those don't matter + // during normal restore + state.windows.forEach(function (aWindow) { + delete aWindow.__lastSessionWindowID; + }); + } + + // clear _maybeDontRestoreTabs because we have restored (or not) + // windows and so they don't matter + state?.windows?.forEach(win => delete win._maybeDontRestoreTabs); + state?._closedWindows?.forEach(win => delete win._maybeDontRestoreTabs); + } catch (ex) { + this._log.error("The session file is invalid: " + ex); + } + } + + // at this point, we've as good as resumed the session, so we can + // clear the resume_session_once flag, if it's set + if ( + !lazy.RunState.isQuitting && + this._prefBranch.getBoolPref("sessionstore.resume_session_once") + ) { + this._prefBranch.setBoolPref("sessionstore.resume_session_once", false); + } + + TelemetryStopwatch.finish("FX_SESSION_RESTORE_STARTUP_INIT_SESSION_MS"); + return state; + }, + + /** + * When initializing session, if we are restoring the last session at startup, + * close open tabs or close windows marked _maybeDontRestoreTabs (if they were closed + * by closing remaining tabs). + * See bug 490136 + */ + _removeExplicitlyClosedTabs(state) { + // Don't restore tabs that has been explicitly closed + for (let i = 0; i < state.windows.length; ) { + const winData = state.windows[i]; + if (winData._maybeDontRestoreTabs) { + if (state.windows.length == 1) { + // it's the last window, we just want to close tabs + let j = 0; + // reset close group (we don't want to append tabs to existing group close). + winData._lastClosedTabGroupCount = -1; + while (winData.tabs.length) { + const tabState = winData.tabs.pop(); + + // Ensure the index is in bounds. + let activeIndex = (tabState.index || tabState.entries.length) - 1; + activeIndex = Math.min(activeIndex, tabState.entries.length - 1); + activeIndex = Math.max(activeIndex, 0); + + let title = ""; + if (activeIndex in tabState.entries) { + title = + tabState.entries[activeIndex].title || + tabState.entries[activeIndex].url; + } + + const tabData = { + state: tabState, + title, + image: tabState.image, + pos: j++, + closedAt: Date.now(), + closedInGroup: true, + }; + if (this._shouldSaveTabState(tabState)) { + this.saveClosedTabData(winData, winData._closedTabs, tabData); + } + } + } else { + // We can remove the window since it doesn't have any + // tabs that we should restore and it's not the only window + if (winData.tabs.some(this._shouldSaveTabState)) { + winData.closedAt = Date.now(); + state._closedWindows.unshift(winData); + } + state.windows.splice(i, 1); + continue; // we don't want to increment the index + } + } + i++; + } + }, + + _initPrefs() { + this._prefBranch = Services.prefs.getBranch("browser."); + + gDebuggingEnabled = this._prefBranch.getBoolPref("sessionstore.debug"); + + Services.prefs.addObserver("browser.sessionstore.debug", () => { + gDebuggingEnabled = this._prefBranch.getBoolPref("sessionstore.debug"); + }); + + this._log = console.createInstance({ + prefix: "SessionStore", + maxLogLevel: gDebuggingEnabled ? "Debug" : "Warn", + }); + + this._max_tabs_undo = this._prefBranch.getIntPref( + "sessionstore.max_tabs_undo" + ); + this._prefBranch.addObserver("sessionstore.max_tabs_undo", this, true); + + this._max_windows_undo = this._prefBranch.getIntPref( + "sessionstore.max_windows_undo" + ); + this._prefBranch.addObserver("sessionstore.max_windows_undo", this, true); + + this._restore_on_demand = this._prefBranch.getBoolPref( + "sessionstore.restore_on_demand" + ); + this._prefBranch.addObserver("sessionstore.restore_on_demand", this, true); + + gResistFingerprintingEnabled = Services.prefs.getBoolPref( + "privacy.resistFingerprinting" + ); + Services.prefs.addObserver("privacy.resistFingerprinting", this); + + this._shistoryInParent = Services.appinfo.sessionHistoryInParent; + }, + + /** + * Called on application shutdown, after notifications: + * quit-application-granted, quit-application + */ + _uninit: function ssi_uninit() { + if (!this._initialized) { + throw new Error("SessionStore is not initialized."); + } + + // Prepare to close the session file and write the last state. + lazy.RunState.setClosing(); + + // save all data for session resuming + if (this._sessionInitialized) { + lazy.SessionSaver.run(); + } + + // clear out priority queue in case it's still holding refs + TabRestoreQueue.reset(); + + // Make sure to cancel pending saves. + lazy.SessionSaver.cancel(); + }, + + /** + * Handle notifications + */ + observe: function ssi_observe(aSubject, aTopic, aData) { + switch (aTopic) { + case "browser-window-before-show": // catch new windows + this.onBeforeBrowserWindowShown(aSubject); + break; + case "domwindowclosed": // catch closed windows + this.onClose(aSubject).then(() => { + this._notifyOfClosedObjectsChange(); + }); + if (gDebuggingEnabled) { + Services.obs.notifyObservers(null, NOTIFY_DOMWINDOWCLOSED_HANDLED); + } + break; + case "quit-application-granted": + let syncShutdown = aData == "syncShutdown"; + this.onQuitApplicationGranted(syncShutdown); + break; + case "browser-lastwindow-close-granted": + this.onLastWindowCloseGranted(); + break; + case "quit-application": + this.onQuitApplication(aData); + break; + case "browser:purge-session-history": // catch sanitization + this.onPurgeSessionHistory(); + this._notifyOfClosedObjectsChange(); + break; + case "browser:purge-session-history-for-domain": + this.onPurgeDomainData(aData); + this._notifyOfClosedObjectsChange(); + break; + case "nsPref:changed": // catch pref changes + this.onPrefChange(aData); + this._notifyOfClosedObjectsChange(); + break; + case "idle-daily": + this.onIdleDaily(); + this._notifyOfClosedObjectsChange(); + break; + case "clear-origin-attributes-data": + let userContextId = 0; + try { + userContextId = JSON.parse(aData).userContextId; + } catch (e) {} + if (userContextId) { + this._forgetTabsWithUserContextId(userContextId); + } + break; + case "browsing-context-did-set-embedder": + if (Services.appinfo.sessionHistoryInParent) { + if ( + aSubject && + aSubject === aSubject.top && + aSubject.isContent && + aSubject.embedderElement && + aSubject.embedderElement.permanentKey + ) { + let permanentKey = aSubject.embedderElement.permanentKey; + this._browserSHistoryListener.get(permanentKey)?.unregister(); + this.getOrCreateSHistoryListener(permanentKey, aSubject, true); + } + } + break; + case "browsing-context-discarded": + if (Services.appinfo.sessionHistoryInParent) { + let permanentKey = aSubject?.embedderElement?.permanentKey; + if (permanentKey) { + this._browserSHistoryListener.get(permanentKey)?.unregister(); + } + } + break; + case "browser-shutdown-tabstate-updated": + if (Services.appinfo.sessionHistoryInParent) { + // Non-SHIP code calls this when the frame script is unloaded. + this.onFinalTabStateUpdateComplete(aSubject); + } + this._notifyOfClosedObjectsChange(); + break; + } + }, + + getOrCreateSHistoryListener( + permanentKey, + browsingContext, + collectImmediately = false + ) { + class SHistoryListener { + constructor() { + this.QueryInterface = ChromeUtils.generateQI([ + "nsISHistoryListener", + "nsISupportsWeakReference", + ]); + + this._browserId = browsingContext.browserId; + this._fromIndex = kNoIndex; + } + + unregister() { + let bc = BrowsingContext.getCurrentTopByBrowserId(this._browserId); + bc?.sessionHistory?.removeSHistoryListener(this); + SessionStoreInternal._browserSHistoryListener.delete(permanentKey); + } + + collect( + permanentKey, // eslint-disable-line no-shadow + browsingContext, // eslint-disable-line no-shadow + { collectFull = true, writeToCache = false } + ) { + // Don't bother doing anything if we haven't seen any navigations. + if (!collectFull && this._fromIndex === kNoIndex) { + return null; + } + + TelemetryStopwatch.start( + "FX_SESSION_RESTORE_COLLECT_SESSION_HISTORY_MS" + ); + + let fromIndex = collectFull ? -1 : this._fromIndex; + this._fromIndex = kNoIndex; + + let historychange = lazy.SessionHistory.collectFromParent( + browsingContext.currentURI?.spec, + true, // Bug 1704574 + browsingContext.sessionHistory, + fromIndex + ); + + if (writeToCache) { + let win = + browsingContext.embedderElement?.ownerGlobal || + browsingContext.currentWindowGlobal?.browsingContext?.window; + + SessionStoreInternal.onTabStateUpdate(permanentKey, win, { + data: { historychange }, + }); + } + + TelemetryStopwatch.finish( + "FX_SESSION_RESTORE_COLLECT_SESSION_HISTORY_MS" + ); + + return historychange; + } + + collectFrom(index) { + if (this._fromIndex <= index) { + // If we already know that we need to update history from index N we + // can ignore any changes that happened with an element with index + // larger than N. + // + // Note: initially we use kNoIndex which is MAX_SAFE_INTEGER which + // means we don't ignore anything here, and in case of navigation in + // the history back and forth cases we use kLastIndex which ignores + // only the subsequent navigations, but not any new elements added. + return; + } + + let bc = BrowsingContext.getCurrentTopByBrowserId(this._browserId); + if (bc?.embedderElement?.frameLoader) { + this._fromIndex = index; + + // Queue a tab state update on the |browser.sessionstore.interval| + // timer. We'll call this.collect() when we receive the update. + bc.embedderElement.frameLoader.requestSHistoryUpdate(); + } + } + + OnHistoryNewEntry(newURI, oldIndex) { + // We use oldIndex - 1 to collect the current entry as well. This makes + // sure to collect any changes that were made to the entry while the + // document was active. + this.collectFrom(oldIndex == -1 ? oldIndex : oldIndex - 1); + } + OnHistoryGotoIndex() { + this.collectFrom(kLastIndex); + } + OnHistoryPurge() { + this.collectFrom(-1); + } + OnHistoryReload() { + this.collectFrom(-1); + return true; + } + OnHistoryReplaceEntry() { + this.collectFrom(-1); + } + } + + if (!Services.appinfo.sessionHistoryInParent) { + throw new Error("This function should only be used with SHIP"); + } + + if (!permanentKey || browsingContext !== browsingContext.top) { + return null; + } + + let sessionHistory = browsingContext.sessionHistory; + if (!sessionHistory) { + return null; + } + + let listener = this._browserSHistoryListener.get(permanentKey); + if (listener) { + return listener; + } + + listener = new SHistoryListener(); + sessionHistory.addSHistoryListener(listener); + this._browserSHistoryListener.set(permanentKey, listener); + + let isAboutBlank = browsingContext.currentURI?.spec === "about:blank"; + + if (collectImmediately && (!isAboutBlank || sessionHistory.count !== 0)) { + listener.collect(permanentKey, browsingContext, { writeToCache: true }); + } + + return listener; + }, + + onTabStateUpdate(permanentKey, win, update) { + // Ignore messages from elements that have crashed + // and not yet been revived. + if (this._crashedBrowsers.has(permanentKey)) { + return; + } + + lazy.TabState.update(permanentKey, update); + this.saveStateDelayed(win); + + // Handle any updates sent by the child after the tab was closed. This + // might be the final update as sent by the "unload" handler but also + // any async update message that was sent before the child unloaded. + let closedTab = this._closedTabs.get(permanentKey); + if (closedTab) { + // Update the closed tab's state. This will be reflected in its + // window's list of closed tabs as that refers to the same object. + lazy.TabState.copyFromCache(permanentKey, closedTab.tabData.state); + } + }, + + onFinalTabStateUpdateComplete(browser) { + let permanentKey = browser.permanentKey; + if ( + this._closedTabs.has(permanentKey) && + !this._crashedBrowsers.has(permanentKey) + ) { + let { winData, closedTabs, tabData } = this._closedTabs.get(permanentKey); + + // We expect no further updates. + this._closedTabs.delete(permanentKey); + + // The tab state no longer needs this reference. + delete tabData.permanentKey; + + // Determine whether the tab state is worth saving. + let shouldSave = this._shouldSaveTabState(tabData.state); + let index = closedTabs.indexOf(tabData); + + if (shouldSave && index == -1) { + // If the tab state is worth saving and we didn't push it onto + // the list of closed tabs when it was closed (because we deemed + // the state not worth saving) then add it to the window's list + // of closed tabs now. + this.saveClosedTabData(winData, closedTabs, tabData); + } else if (!shouldSave && index > -1) { + // Remove from the list of closed tabs. The update messages sent + // after the tab was closed changed enough state so that we no + // longer consider its data interesting enough to keep around. + this.removeClosedTabData(winData, closedTabs, index); + } + } + + // If this the final message we need to resolve all pending flush + // requests for the given browser as they might have been sent too + // late and will never respond. If they have been sent shortly after + // switching a browser's remoteness there isn't too much data to skip. + lazy.TabStateFlusher.resolveAll(browser); + + this._browserSHistoryListener.get(permanentKey)?.unregister(); + this._restoreListeners.get(permanentKey)?.unregister(); + + Services.obs.notifyObservers(browser, NOTIFY_BROWSER_SHUTDOWN_FLUSH); + }, + + updateSessionStoreFromTablistener( + browser, + browsingContext, + permanentKey, + update, + forStorage = false + ) { + permanentKey = browser?.permanentKey ?? permanentKey; + if (!permanentKey) { + return; + } + + // Ignore sessionStore update from previous epochs + if (!this.isCurrentEpoch(permanentKey, update.epoch)) { + return; + } + + if (browsingContext.isReplaced) { + return; + } + + if (Services.appinfo.sessionHistoryInParent) { + let listener = this.getOrCreateSHistoryListener( + permanentKey, + browsingContext + ); + + if (listener) { + let historychange = + // If it is not the scheduled update (tab closed, window closed etc), + // try to store the loading non-web-controlled page opened in _blank + // first. + (forStorage && + lazy.SessionHistory.collectNonWebControlledBlankLoadingSession( + browsingContext + )) || + listener.collect(permanentKey, browsingContext, { + collectFull: !!update.sHistoryNeeded, + writeToCache: false, + }); + + if (historychange) { + update.data.historychange = historychange; + } + } + } + + let win = + browser?.ownerGlobal ?? + browsingContext.currentWindowGlobal?.browsingContext?.window; + + this.onTabStateUpdate(permanentKey, win, update); + }, + + /** + * This method handles incoming messages sent by the session store content + * script via the Frame Message Manager or Parent Process Message Manager, + * and thus enables communication with OOP tabs. + */ + receiveMessage(aMessage) { + // If we got here, that means we're dealing with a frame message + // manager message, so the target will be a . + var browser = aMessage.target; + let win = browser.ownerGlobal; + let tab = win ? win.gBrowser.getTabForBrowser(browser) : null; + + // Ensure we receive only specific messages from s that + // have no tab or window assigned, e.g. the ones that preload + // about:newtab pages, or windows that have closed. + if (!tab && !NOTAB_MESSAGES.has(aMessage.name)) { + throw new Error( + `received unexpected message '${aMessage.name}' ` + + `from a browser that has no tab or window` + ); + } + + let data = aMessage.data || {}; + let hasEpoch = data.hasOwnProperty("epoch"); + + // Most messages sent by frame scripts require to pass an epoch. + if (!hasEpoch && !NOEPOCH_MESSAGES.has(aMessage.name)) { + throw new Error(`received message '${aMessage.name}' without an epoch`); + } + + // Ignore messages from previous epochs. + if (hasEpoch && !this.isCurrentEpoch(browser.permanentKey, data.epoch)) { + return; + } + + switch (aMessage.name) { + case "SessionStore:update": + // |browser.frameLoader| might be empty if the browser was already + // destroyed and its tab removed. In that case we still have the last + // frameLoader we know about to compare. + let frameLoader = + browser.frameLoader || + this._lastKnownFrameLoader.get(browser.permanentKey); + + // If the message isn't targeting the latest frameLoader discard it. + if (frameLoader != aMessage.targetFrameLoader) { + return; + } + + this.onTabStateUpdate(browser.permanentKey, browser.ownerGlobal, data); + + // SHIP code will call this when it receives "browser-shutdown-tabstate-updated" + if (data.isFinal) { + if (!Services.appinfo.sessionHistoryInParent) { + this.onFinalTabStateUpdateComplete(browser); + } + } else if (data.flushID) { + // This is an update kicked off by an async flush request. Notify the + // TabStateFlusher so that it can finish the request and notify its + // consumer that's waiting for the flush to be done. + lazy.TabStateFlusher.resolve(browser, data.flushID); + } + + break; + case "SessionStore:restoreHistoryComplete": + this._restoreHistoryComplete(browser, data); + break; + case "SessionStore:restoreTabContentStarted": + this._restoreTabContentStarted(browser, data); + break; + case "SessionStore:restoreTabContentComplete": + this._restoreTabContentComplete(browser, data); + break; + case "SessionStore:error": + lazy.TabStateFlusher.resolveAll( + browser, + false, + "Received error from the content process" + ); + break; + default: + throw new Error(`received unknown message '${aMessage.name}'`); + } + }, + + /* ........ Window Event Handlers .............. */ + + /** + * Implement EventListener for handling various window and tab events + */ + handleEvent: function ssi_handleEvent(aEvent) { + let win = aEvent.currentTarget.ownerGlobal; + let target = aEvent.originalTarget; + switch (aEvent.type) { + case "TabOpen": + this.onTabAdd(win); + break; + case "TabBrowserInserted": + this.onTabBrowserInserted(win, target); + break; + case "TabClose": + // `adoptedBy` will be set if the tab was closed because it is being + // moved to a new window. + if (aEvent.detail.adoptedBy) { + this.onMoveToNewWindow( + target.linkedBrowser, + aEvent.detail.adoptedBy.linkedBrowser + ); + } else { + this.onTabClose(win, target); + } + this.onTabRemove(win, target); + this._notifyOfClosedObjectsChange(); + break; + case "TabSelect": + this.onTabSelect(win); + break; + case "TabShow": + this.onTabShow(win, target); + break; + case "TabHide": + this.onTabHide(win, target); + break; + case "TabPinned": + case "TabUnpinned": + case "SwapDocShells": + this.saveStateDelayed(win); + break; + case "oop-browser-crashed": + case "oop-browser-buildid-mismatch": + if (aEvent.isTopFrame) { + this.onBrowserCrashed(target); + } + break; + case "XULFrameLoaderCreated": + if ( + target.namespaceURI == XUL_NS && + target.localName == "browser" && + target.frameLoader && + target.permanentKey + ) { + this._lastKnownFrameLoader.set( + target.permanentKey, + target.frameLoader + ); + this.resetEpoch(target.permanentKey, target.frameLoader); + } + break; + default: + throw new Error(`unhandled event ${aEvent.type}?`); + } + this._clearRestoringWindows(); + }, + + /** + * Generate a unique window identifier + * @return string + * A unique string to identify a window + */ + _generateWindowID: function ssi_generateWindowID() { + return "window" + this._nextWindowID++; + }, + + /** + * Registers and tracks a given window. + * + * @param aWindow + * Window reference + */ + onLoad(aWindow) { + // return if window has already been initialized + if (aWindow && aWindow.__SSi && this._windows[aWindow.__SSi]) { + return; + } + + // ignore windows opened while shutting down + if (lazy.RunState.isQuitting) { + return; + } + + // Assign the window a unique identifier we can use to reference + // internal data about the window. + aWindow.__SSi = this._generateWindowID(); + + let mm = aWindow.getGroupMessageManager("browsers"); + MESSAGES.forEach(msg => { + let listenWhenClosed = CLOSED_MESSAGES.has(msg); + mm.addMessageListener(msg, this, listenWhenClosed); + }); + + // Load the frame script after registering listeners. + if (!Services.appinfo.sessionHistoryInParent) { + mm.loadFrameScript( + "chrome://browser/content/content-sessionStore.js", + true, + true + ); + } + + // and create its data object + this._windows[aWindow.__SSi] = { + tabs: [], + selected: 0, + _closedTabs: [], + _lastClosedTabGroupCount: -1, + busy: false, + chromeFlags: aWindow.docShell.treeOwner + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIAppWindow).chromeFlags, + }; + + if (PrivateBrowsingUtils.isWindowPrivate(aWindow)) { + this._windows[aWindow.__SSi].isPrivate = true; + } + if (!this._isWindowLoaded(aWindow)) { + this._windows[aWindow.__SSi]._restoring = true; + } + if (!aWindow.toolbar.visible) { + this._windows[aWindow.__SSi].isPopup = true; + } + + let tabbrowser = aWindow.gBrowser; + + // add tab change listeners to all already existing tabs + for (let i = 0; i < tabbrowser.tabs.length; i++) { + this.onTabBrowserInserted(aWindow, tabbrowser.tabs[i]); + } + // notification of tab add/remove/selection/show/hide + TAB_EVENTS.forEach(function (aEvent) { + tabbrowser.tabContainer.addEventListener(aEvent, this, true); + }, this); + + // Keep track of a browser's latest frameLoader. + aWindow.gBrowser.addEventListener("XULFrameLoaderCreated", this); + }, + + /** + * Initializes a given window. + * + * Windows are registered as soon as they are created but we need to wait for + * the session file to load, and the initial window's delayed startup to + * finish before initializing a window, i.e. restoring data into it. + * + * @param aWindow + * Window reference + * @param aInitialState + * The initial state to be loaded after startup (optional) + */ + initializeWindow(aWindow, aInitialState = null) { + let isPrivateWindow = PrivateBrowsingUtils.isWindowPrivate(aWindow); + + // perform additional initialization when the first window is loading + if (lazy.RunState.isStopped) { + lazy.RunState.setRunning(); + + // restore a crashed session resp. resume the last session if requested + if (aInitialState) { + // Don't write to disk right after startup. Set the last time we wrote + // to disk to NOW() to enforce a full interval before the next write. + lazy.SessionSaver.updateLastSaveTime(); + + if (isPrivateWindow) { + // We're starting with a single private window. Save the state we + // actually wanted to restore so that we can do it later in case + // the user opens another, non-private window. + this._deferredInitialState = lazy.SessionStartup.state; + + // Nothing to restore now, notify observers things are complete. + Services.obs.notifyObservers(null, NOTIFY_WINDOWS_RESTORED); + Services.obs.notifyObservers( + null, + "sessionstore-one-or-no-tab-restored" + ); + this._deferredAllWindowsRestored.resolve(); + } else { + TelemetryTimestamps.add("sessionRestoreRestoring"); + this._restoreCount = aInitialState.windows + ? aInitialState.windows.length + : 0; + + // global data must be restored before restoreWindow is called so that + // it happens before observers are notified + this._globalState.setFromState(aInitialState); + + // Restore session cookies before loading any tabs. + lazy.SessionCookies.restore(aInitialState.cookies || []); + + let overwrite = this._isCmdLineEmpty(aWindow, aInitialState); + let options = { firstWindow: true, overwriteTabs: overwrite }; + this.restoreWindows(aWindow, aInitialState, options); + } + } else { + // Nothing to restore, notify observers things are complete. + Services.obs.notifyObservers(null, NOTIFY_WINDOWS_RESTORED); + Services.obs.notifyObservers( + null, + "sessionstore-one-or-no-tab-restored" + ); + this._deferredAllWindowsRestored.resolve(); + } + // this window was opened by _openWindowWithState + } else if (!this._isWindowLoaded(aWindow)) { + // We want to restore windows after all windows have opened (since bug + // 1034036), so bail out here. + return; + // The user opened another, non-private window after starting up with + // a single private one. Let's restore the session we actually wanted to + // restore at startup. + } else if ( + this._deferredInitialState && + !isPrivateWindow && + aWindow.toolbar.visible + ) { + // global data must be restored before restoreWindow is called so that + // it happens before observers are notified + this._globalState.setFromState(this._deferredInitialState); + + this._restoreCount = this._deferredInitialState.windows + ? this._deferredInitialState.windows.length + : 0; + this.restoreWindows(aWindow, this._deferredInitialState, { + firstWindow: true, + }); + this._deferredInitialState = null; + } else if ( + this._restoreLastWindow && + aWindow.toolbar.visible && + this._closedWindows.length && + !isPrivateWindow + ) { + // default to the most-recently closed window + // don't use popup windows + let closedWindowState = null; + let closedWindowIndex; + for (let i = 0; i < this._closedWindows.length; i++) { + // Take the first non-popup, point our object at it, and break out. + if (!this._closedWindows[i].isPopup) { + closedWindowState = this._closedWindows[i]; + closedWindowIndex = i; + break; + } + } + + if (closedWindowState) { + let newWindowState; + if ( + AppConstants.platform == "macosx" || + !lazy.SessionStartup.willRestore() + ) { + // We want to split the window up into pinned tabs and unpinned tabs. + // Pinned tabs should be restored. If there are any remaining tabs, + // they should be added back to _closedWindows. + // We'll cheat a little bit and reuse _prepDataForDeferredRestore + // even though it wasn't built exactly for this. + let [appTabsState, normalTabsState] = + this._prepDataForDeferredRestore({ + windows: [closedWindowState], + }); + + // These are our pinned tabs, which we should restore + if (appTabsState.windows.length) { + newWindowState = appTabsState.windows[0]; + delete newWindowState.__lastSessionWindowID; + } + + // In case there were no unpinned tabs, remove the window from _closedWindows + if (!normalTabsState.windows.length) { + this._removeClosedWindow(closedWindowIndex); + // Or update _closedWindows with the modified state + } else { + delete normalTabsState.windows[0].__lastSessionWindowID; + this._closedWindows[closedWindowIndex] = normalTabsState.windows[0]; + } + } else { + // If we're just restoring the window, make sure it gets removed from + // _closedWindows. + this._removeClosedWindow(closedWindowIndex); + newWindowState = closedWindowState; + delete newWindowState.hidden; + } + + if (newWindowState) { + // Ensure that the window state isn't hidden + this._restoreCount = 1; + let state = { windows: [newWindowState] }; + let options = { overwriteTabs: this._isCmdLineEmpty(aWindow, state) }; + this.restoreWindow(aWindow, newWindowState, options); + } + } + // we actually restored the session just now. + this._prefBranch.setBoolPref("sessionstore.resume_session_once", false); + } + if (this._restoreLastWindow && aWindow.toolbar.visible) { + // always reset (if not a popup window) + // we don't want to restore a window directly after, for example, + // undoCloseWindow was executed. + this._restoreLastWindow = false; + } + }, + + /** + * Called right before a new browser window is shown. + * @param aWindow + * Window reference + */ + onBeforeBrowserWindowShown(aWindow) { + // Register the window. + this.onLoad(aWindow); + + // Some are waiting for this window to be shown, which is now, so let's resolve + // the deferred operation. + let deferred = WINDOW_SHOWING_PROMISES.get(aWindow); + if (deferred) { + deferred.resolve(aWindow); + WINDOW_SHOWING_PROMISES.delete(aWindow); + } + + // Just call initializeWindow() directly if we're initialized already. + if (this._sessionInitialized) { + this.initializeWindow(aWindow); + return; + } + + // The very first window that is opened creates a promise that is then + // re-used by all subsequent windows. The promise will be used to tell + // when we're ready for initialization. + if (!this._promiseReadyForInitialization) { + // Wait for the given window's delayed startup to be finished. + let promise = new Promise(resolve => { + Services.obs.addObserver(function obs(subject, topic) { + if (aWindow == subject) { + Services.obs.removeObserver(obs, topic); + resolve(); + } + }, "browser-delayed-startup-finished"); + }); + + // We are ready for initialization as soon as the session file has been + // read from disk and the initial window's delayed startup has finished. + this._promiseReadyForInitialization = Promise.all([ + promise, + lazy.SessionStartup.onceInitialized, + ]); + } + + // We can't call this.onLoad since initialization + // hasn't completed, so we'll wait until it is done. + // Even if additional windows are opened and wait + // for initialization as well, the first opened + // window should execute first, and this.onLoad + // will be called with the initialState. + this._promiseReadyForInitialization + .then(() => { + if (aWindow.closed) { + return; + } + + if (this._sessionInitialized) { + this.initializeWindow(aWindow); + } else { + let initialState = this.initSession(); + this._sessionInitialized = true; + + if (initialState) { + Services.obs.notifyObservers(null, NOTIFY_RESTORING_ON_STARTUP); + } + TelemetryStopwatch.start( + "FX_SESSION_RESTORE_STARTUP_ONLOAD_INITIAL_WINDOW_MS" + ); + this.initializeWindow(aWindow, initialState); + TelemetryStopwatch.finish( + "FX_SESSION_RESTORE_STARTUP_ONLOAD_INITIAL_WINDOW_MS" + ); + + // Let everyone know we're done. + this._deferredInitialized.resolve(); + } + }) + .catch(console.error); + }, + + /** + * On window close... + * - remove event listeners from tabs + * - save all window data + * @param aWindow + * Window reference + * + * @returns a Promise + */ + onClose: function ssi_onClose(aWindow) { + let completionPromise = Promise.resolve(); + // this window was about to be restored - conserve its original data, if any + let isFullyLoaded = this._isWindowLoaded(aWindow); + if (!isFullyLoaded) { + if (!aWindow.__SSi) { + aWindow.__SSi = this._generateWindowID(); + } + + let restoreID = WINDOW_RESTORE_IDS.get(aWindow); + this._windows[aWindow.__SSi] = + this._statesToRestore[restoreID].windows[0]; + delete this._statesToRestore[restoreID]; + WINDOW_RESTORE_IDS.delete(aWindow); + } + + // ignore windows not tracked by SessionStore + if (!aWindow.__SSi || !this._windows[aWindow.__SSi]) { + return completionPromise; + } + + // notify that the session store will stop tracking this window so that + // extensions can store any data about this window in session store before + // that's not possible anymore + let event = aWindow.document.createEvent("Events"); + event.initEvent("SSWindowClosing", true, false); + aWindow.dispatchEvent(event); + + if (this.windowToFocus && this.windowToFocus == aWindow) { + delete this.windowToFocus; + } + + var tabbrowser = aWindow.gBrowser; + + let browsers = Array.from(tabbrowser.browsers); + + TAB_EVENTS.forEach(function (aEvent) { + tabbrowser.tabContainer.removeEventListener(aEvent, this, true); + }, this); + + aWindow.gBrowser.removeEventListener("XULFrameLoaderCreated", this); + + let winData = this._windows[aWindow.__SSi]; + + // Collect window data only when *not* closed during shutdown. + if (lazy.RunState.isRunning) { + // Grab the most recent window data. The tab data will be updated + // once we finish flushing all of the messages from the tabs. + let tabMap = this._collectWindowData(aWindow); + + for (let [tab, tabData] of tabMap) { + let permanentKey = tab.linkedBrowser.permanentKey; + this._closedWindowTabs.set(permanentKey, tabData); + } + + if (isFullyLoaded && !winData.title) { + winData.title = + tabbrowser.selectedBrowser.contentTitle || + tabbrowser.selectedTab.label; + } + + if (AppConstants.platform != "macosx") { + // Until we decide otherwise elsewhere, this window is part of a series + // of closing windows to quit. + winData._shouldRestore = true; + } + + // Store the window's close date to figure out when each individual tab + // was closed. This timestamp should allow re-arranging data based on how + // recently something was closed. + winData.closedAt = Date.now(); + + // we don't want to save the busy state + delete winData.busy; + + // When closing windows one after the other until Firefox quits, we + // will move those closed in series back to the "open windows" bucket + // before writing to disk. If however there is only a single window + // with tabs we deem not worth saving then we might end up with a + // random closed or even a pop-up window re-opened. To prevent that + // we explicitly allow saving an "empty" window state. + let isLastWindow = this.isLastRestorableWindow(); + + // clear this window from the list, since it has definitely been closed. + delete this._windows[aWindow.__SSi]; + + // This window has the potential to be saved in the _closedWindows + // array (maybeSaveClosedWindows gets the final call on that). + this._saveableClosedWindowData.add(winData); + + // Now we have to figure out if this window is worth saving in the _closedWindows + // Object. + // + // We're about to flush the tabs from this window, but it's possible that we + // might never hear back from the content process(es) in time before the user + // chooses to restore the closed window. So we do the following: + // + // 1) Use the tab state cache to determine synchronously if the window is + // worth stashing in _closedWindows. + // 2) Flush the window. + // 3) When the flush is complete, revisit our decision to store the window + // in _closedWindows, and add/remove as necessary. + if (!winData.isPrivate) { + // Remove any open private tabs the window may contain. + lazy.PrivacyFilter.filterPrivateTabs(winData); + this.maybeSaveClosedWindow(winData, isLastWindow); + } + + completionPromise = lazy.TabStateFlusher.flushWindow(aWindow).then(() => { + // At this point, aWindow is closed! You should probably not try to + // access any DOM elements from aWindow within this callback unless + // you're holding on to them in the closure. + + WINDOW_FLUSHING_PROMISES.delete(aWindow); + + for (let browser of browsers) { + if (this._closedWindowTabs.has(browser.permanentKey)) { + let tabData = this._closedWindowTabs.get(browser.permanentKey); + lazy.TabState.copyFromCache(browser.permanentKey, tabData); + this._closedWindowTabs.delete(browser.permanentKey); + } + } + + // Save non-private windows if they have at + // least one saveable tab or are the last window. + if (!winData.isPrivate) { + // It's possible that a tab switched its privacy state at some point + // before our flush, so we need to filter again. + lazy.PrivacyFilter.filterPrivateTabs(winData); + this.maybeSaveClosedWindow(winData, isLastWindow); + } + + // Update the tabs data now that we've got the most + // recent information. + this.cleanUpWindow(aWindow, winData, browsers); + + // save the state without this window to disk + this.saveStateDelayed(); + }); + + // Here we might override a flush already in flight, but that's fine + // because `completionPromise` will always resolve after the old flush + // resolves. + WINDOW_FLUSHING_PROMISES.set(aWindow, completionPromise); + } else { + this.cleanUpWindow(aWindow, winData, browsers); + } + + for (let i = 0; i < tabbrowser.tabs.length; i++) { + this.onTabRemove(aWindow, tabbrowser.tabs[i], true); + } + + return completionPromise; + }, + + /** + * Clean up the message listeners on a window that has finally + * gone away. Call this once you're sure you don't want to hear + * from any of this windows tabs from here forward. + * + * @param aWindow + * The browser window we're cleaning up. + * @param winData + * The data for the window that we should hold in the + * DyingWindowCache in case anybody is still holding a + * reference to it. + */ + cleanUpWindow(aWindow, winData, browsers) { + // Any leftover TabStateFlusher Promises need to be resolved now, + // since we're about to remove the message listeners. + for (let browser of browsers) { + lazy.TabStateFlusher.resolveAll(browser); + } + + // Cache the window state until it is completely gone. + DyingWindowCache.set(aWindow, winData); + + let mm = aWindow.getGroupMessageManager("browsers"); + MESSAGES.forEach(msg => mm.removeMessageListener(msg, this)); + + this._saveableClosedWindowData.delete(winData); + delete aWindow.__SSi; + }, + + /** + * Decides whether or not a closed window should be put into the + * _closedWindows Object. This might be called multiple times per + * window, and will do the right thing of moving the window data + * in or out of _closedWindows if the winData indicates that our + * need for saving it has changed. + * + * @param winData + * The data for the closed window that we might save. + * @param isLastWindow + * Whether or not the window being closed is the last + * browser window. Callers of this function should pass + * in the value of SessionStoreInternal.atLastWindow for + * this argument, and pass in the same value if they happen + * to call this method again asynchronously (for example, after + * a window flush). + */ + maybeSaveClosedWindow(winData, isLastWindow) { + // Make sure SessionStore is still running, and make sure that we + // haven't chosen to forget this window. + if ( + lazy.RunState.isRunning && + this._saveableClosedWindowData.has(winData) + ) { + // Determine whether the window has any tabs worth saving. + let hasSaveableTabs = winData.tabs.some(this._shouldSaveTabState); + + // Note that we might already have this window stored in + // _closedWindows from a previous call to this function. + let winIndex = this._closedWindows.indexOf(winData); + let alreadyStored = winIndex != -1; + let shouldStore = hasSaveableTabs || isLastWindow; + + if (shouldStore && !alreadyStored) { + let index = this._closedWindows.findIndex(win => { + return win.closedAt < winData.closedAt; + }); + + // If we found no tab closed before our + // tab then just append it to the list. + if (index == -1) { + index = this._closedWindows.length; + } + + // About to save the closed window, add a unique ID. + winData.closedId = this._nextClosedId++; + + // Insert tabData at the right position. + this._closedWindows.splice(index, 0, winData); + this._capClosedWindows(); + this._closedObjectsChanged = true; + // The first time we close a window, ensure it can be restored from the + // hidden window. + if ( + AppConstants.platform == "macosx" && + this._closedWindows.length == 1 + ) { + // Fake a popupshowing event so shortcuts work: + let window = Services.appShell.hiddenDOMWindow; + let historyMenu = window.document.getElementById("history-menu"); + let evt = new window.CustomEvent("popupshowing", { bubbles: true }); + historyMenu.menupopup.dispatchEvent(evt); + } + } else if (!shouldStore && alreadyStored) { + this._removeClosedWindow(winIndex); + } + } + }, + + /** + * On quit application granted + */ + onQuitApplicationGranted: function ssi_onQuitApplicationGranted( + syncShutdown = false + ) { + // Collect an initial snapshot of window data before we do the flush. + let index = 0; + for (let window of this._orderedBrowserWindows) { + this._collectWindowData(window); + this._windows[window.__SSi].zIndex = ++index; + } + + // Now add an AsyncShutdown blocker that'll spin the event loop + // until the windows have all been flushed. + + // This progress object will track the state of async window flushing + // and will help us debug things that go wrong with our AsyncShutdown + // blocker. + let progress = { total: -1, current: -1 }; + + // We're going down! Switch state so that we treat closing windows and + // tabs correctly. + lazy.RunState.setQuitting(); + + if (!syncShutdown) { + // We've got some time to shut down, so let's do this properly that there + // will be a complete session available upon next startup. + // To prevent a blocker from taking longer than the DELAY_CRASH_MS limit + // (which will cause a crash) of AsyncShutdown whilst flushing all windows, + // we resolve the Promise blocker once: + // 1. the flush duration exceeds 10 seconds before DELAY_CRASH_MS, or + // 2. 'oop-frameloader-crashed', or + // 3. 'ipc:content-shutdown' is observed. + lazy.AsyncShutdown.quitApplicationGranted.addBlocker( + "SessionStore: flushing all windows", + () => { + // Set up the list of promises that will signal a complete sessionstore + // shutdown: either all data is saved, or we crashed or the message IPC + // channel went away in the meantime. + let promises = [this.flushAllWindowsAsync(progress)]; + + const observeTopic = topic => { + let deferred = lazy.PromiseUtils.defer(); + const observer = subject => { + // Skip abort on ipc:content-shutdown if not abnormal/crashed + subject.QueryInterface(Ci.nsIPropertyBag2); + if ( + !(topic == "ipc:content-shutdown" && !subject.get("abnormal")) + ) { + deferred.resolve(); + } + }; + const cleanup = () => { + try { + Services.obs.removeObserver(observer, topic); + } catch (ex) { + console.error( + "SessionStore: exception whilst flushing all windows: ", + ex + ); + } + }; + Services.obs.addObserver(observer, topic); + deferred.promise.then(cleanup, cleanup); + return deferred; + }; + + // Build a list of deferred executions that require cleanup once the + // Promise race is won. + // Ensure that the timer fires earlier than the AsyncShutdown crash timer. + let waitTimeMaxMs = Math.max( + 0, + lazy.AsyncShutdown.DELAY_CRASH_MS - 10000 + ); + let defers = [ + this.looseTimer(waitTimeMaxMs), + + // FIXME: We should not be aborting *all* flushes when a single + // content process crashes here. + observeTopic("oop-frameloader-crashed"), + observeTopic("ipc:content-shutdown"), + ]; + // Add these monitors to the list of Promises to start the race. + promises.push(...defers.map(deferred => deferred.promise)); + + return Promise.race(promises).then(() => { + // When a Promise won the race, make sure we clean up the running + // monitors. + defers.forEach(deferred => deferred.reject()); + }); + }, + () => progress + ); + } else { + // We have to shut down NOW, which means we only get to save whatever + // we already had cached. + } + }, + + /** + * An async Task that iterates all open browser windows and flushes + * any outstanding messages from their tabs. This will also close + * all of the currently open windows while we wait for the flushes + * to complete. + * + * @param progress (Object) + * Optional progress object that will be updated as async + * window flushing progresses. flushAllWindowsSync will + * write to the following properties: + * + * total (int): + * The total number of windows to be flushed. + * current (int): + * The current window that we're waiting for a flush on. + * + * @return Promise + */ + async flushAllWindowsAsync(progress = {}) { + let windowPromises = new Map(WINDOW_FLUSHING_PROMISES); + WINDOW_FLUSHING_PROMISES.clear(); + + // We collect flush promises and close each window immediately so that + // the user can't start changing any window state while we're waiting + // for the flushes to finish. + for (let window of this._browserWindows) { + windowPromises.set(window, lazy.TabStateFlusher.flushWindow(window)); + + // We have to wait for these messages to come up from + // each window and each browser. In the meantime, hide + // the windows to improve perceived shutdown speed. + let baseWin = window.docShell.treeOwner.QueryInterface(Ci.nsIBaseWindow); + baseWin.visibility = false; + } + + progress.total = windowPromises.size; + progress.current = 0; + + // We'll iterate through the Promise array, yielding each one, so as to + // provide useful progress information to AsyncShutdown. + for (let [win, promise] of windowPromises) { + await promise; + + // We may have already stopped tracking this window in onClose, which is + // fine as we would've collected window data there as well. + if (win.__SSi && this._windows[win.__SSi]) { + this._collectWindowData(win); + } + + progress.current++; + } + + // We must cache this because _getTopWindow will always + // return null by the time quit-application occurs. + var activeWindow = this._getTopWindow(); + if (activeWindow) { + this.activeWindowSSiCache = activeWindow.__SSi || ""; + } + DirtyWindows.clear(); + }, + + /** + * On last browser window close + */ + onLastWindowCloseGranted: function ssi_onLastWindowCloseGranted() { + // last browser window is quitting. + // remember to restore the last window when another browser window is opened + // do not account for pref(resume_session_once) at this point, as it might be + // set by another observer getting this notice after us + this._restoreLastWindow = true; + }, + + /** + * On quitting application + * @param aData + * String type of quitting + */ + onQuitApplication: function ssi_onQuitApplication(aData) { + if (aData == "restart" || aData == "os-restart") { + if (!PrivateBrowsingUtils.permanentPrivateBrowsing) { + if ( + aData == "os-restart" && + !this._prefBranch.getBoolPref("sessionstore.resume_session_once") + ) { + this._prefBranch.setBoolPref( + "sessionstore.resuming_after_os_restart", + true + ); + } + this._prefBranch.setBoolPref("sessionstore.resume_session_once", true); + } + + // The browser:purge-session-history notification fires after the + // quit-application notification so unregister the + // browser:purge-session-history notification to prevent clearing + // session data on disk on a restart. It is also unnecessary to + // perform any other sanitization processing on a restart as the + // browser is about to exit anyway. + Services.obs.removeObserver(this, "browser:purge-session-history"); + } + + if (aData != "restart") { + // Throw away the previous session on shutdown without notification + LastSession.clear(true); + } + + this._uninit(); + }, + + /** + * On purge of session history + */ + onPurgeSessionHistory: function ssi_onPurgeSessionHistory() { + lazy.SessionFile.wipe(); + // If the browser is shutting down, simply return after clearing the + // session data on disk as this notification fires after the + // quit-application notification so the browser is about to exit. + if (lazy.RunState.isQuitting) { + return; + } + LastSession.clear(); + + let openWindows = {}; + // Collect open windows. + for (let window of this._browserWindows) { + openWindows[window.__SSi] = true; + } + + // also clear all data about closed tabs and windows + for (let ix in this._windows) { + if (ix in openWindows) { + if (this._windows[ix]._closedTabs.length) { + this._windows[ix]._closedTabs = []; + this._closedObjectsChanged = true; + } + } else { + delete this._windows[ix]; + } + } + // also clear all data about closed windows + if (this._closedWindows.length) { + this._closedWindows = []; + this._closedObjectsChanged = true; + } + // give the tabbrowsers a chance to clear their histories first + var win = this._getTopWindow(); + if (win) { + win.setTimeout(() => lazy.SessionSaver.run(), 0); + } else if (lazy.RunState.isRunning) { + lazy.SessionSaver.run(); + } + + this._clearRestoringWindows(); + this._saveableClosedWindowData = new WeakSet(); + }, + + /** + * On purge of domain data + * @param {string} aDomain + * The domain we want to purge data for + */ + onPurgeDomainData: function ssi_onPurgeDomainData(aDomain) { + // does a session history entry contain a url for the given domain? + function containsDomain(aEntry) { + let host; + try { + host = Services.io.newURI(aEntry.url).host; + } catch (e) { + // The given URL probably doesn't have a host. + } + if (host && Services.eTLD.hasRootDomain(host, aDomain)) { + return true; + } + return aEntry.children && aEntry.children.some(containsDomain, this); + } + // remove all closed tabs containing a reference to the given domain + for (let ix in this._windows) { + let closedTabs = this._windows[ix]._closedTabs; + for (let i = closedTabs.length - 1; i >= 0; i--) { + if (closedTabs[i].state.entries.some(containsDomain, this)) { + closedTabs.splice(i, 1); + this._closedObjectsChanged = true; + } + } + } + // remove all open & closed tabs containing a reference to the given + // domain in closed windows + for (let ix = this._closedWindows.length - 1; ix >= 0; ix--) { + let closedTabs = this._closedWindows[ix]._closedTabs; + let openTabs = this._closedWindows[ix].tabs; + let openTabCount = openTabs.length; + for (let i = closedTabs.length - 1; i >= 0; i--) { + if (closedTabs[i].state.entries.some(containsDomain, this)) { + closedTabs.splice(i, 1); + } + } + for (let j = openTabs.length - 1; j >= 0; j--) { + if (openTabs[j].entries.some(containsDomain, this)) { + openTabs.splice(j, 1); + if (this._closedWindows[ix].selected > j) { + this._closedWindows[ix].selected--; + } + } + } + if (!openTabs.length) { + this._closedWindows.splice(ix, 1); + } else if (openTabs.length != openTabCount) { + // Adjust the window's title if we removed an open tab + let selectedTab = openTabs[this._closedWindows[ix].selected - 1]; + // some duplication from restoreHistory - make sure we get the correct title + let activeIndex = (selectedTab.index || selectedTab.entries.length) - 1; + if (activeIndex >= selectedTab.entries.length) { + activeIndex = selectedTab.entries.length - 1; + } + this._closedWindows[ix].title = selectedTab.entries[activeIndex].title; + } + } + + if (lazy.RunState.isRunning) { + lazy.SessionSaver.run(); + } + + this._clearRestoringWindows(); + }, + + /** + * On preference change + * @param aData + * String preference changed + */ + onPrefChange: function ssi_onPrefChange(aData) { + switch (aData) { + // if the user decreases the max number of closed tabs they want + // preserved update our internal states to match that max + case "sessionstore.max_tabs_undo": + this._max_tabs_undo = this._prefBranch.getIntPref( + "sessionstore.max_tabs_undo" + ); + for (let ix in this._windows) { + if (this._windows[ix]._closedTabs.length > this._max_tabs_undo) { + this._windows[ix]._closedTabs.splice( + this._max_tabs_undo, + this._windows[ix]._closedTabs.length + ); + this._closedObjectsChanged = true; + } + } + break; + case "sessionstore.max_windows_undo": + this._max_windows_undo = this._prefBranch.getIntPref( + "sessionstore.max_windows_undo" + ); + this._capClosedWindows(); + break; + case "privacy.resistFingerprinting": + gResistFingerprintingEnabled = Services.prefs.getBoolPref( + "privacy.resistFingerprinting" + ); + break; + case "sessionstore.restore_on_demand": + this._restore_on_demand = this._prefBranch.getBoolPref( + "sessionstore.restore_on_demand" + ); + break; + } + }, + + /** + * save state when new tab is added + * @param aWindow + * Window reference + */ + onTabAdd: function ssi_onTabAdd(aWindow) { + this.saveStateDelayed(aWindow); + }, + + /** + * set up listeners for a new tab + * @param aWindow + * Window reference + * @param aTab + * Tab reference + */ + onTabBrowserInserted: function ssi_onTabBrowserInserted(aWindow, aTab) { + let browser = aTab.linkedBrowser; + browser.addEventListener("SwapDocShells", this); + browser.addEventListener("oop-browser-crashed", this); + browser.addEventListener("oop-browser-buildid-mismatch", this); + + if (browser.frameLoader) { + this._lastKnownFrameLoader.set(browser.permanentKey, browser.frameLoader); + } + + // Only restore if browser has been lazy. + if ( + TAB_LAZY_STATES.has(aTab) && + !TAB_STATE_FOR_BROWSER.has(browser) && + lazy.TabStateCache.get(browser.permanentKey) + ) { + let tabState = lazy.TabState.clone(aTab, TAB_CUSTOM_VALUES.get(aTab)); + this.restoreTab(aTab, tabState); + } + + // The browser has been inserted now, so lazy data is no longer relevant. + TAB_LAZY_STATES.delete(aTab); + }, + + /** + * remove listeners for a tab + * @param aWindow + * Window reference + * @param aTab + * Tab reference + * @param aNoNotification + * bool Do not save state if we're updating an existing tab + */ + onTabRemove: function ssi_onTabRemove(aWindow, aTab, aNoNotification) { + this.cleanUpRemovedBrowser(aTab); + + if (!aNoNotification) { + this.saveStateDelayed(aWindow); + } + }, + + /** + * When a tab closes, collect its properties + * @param aWindow + * Window reference + * @param aTab + * Tab reference + */ + onTabClose: function ssi_onTabClose(aWindow, aTab) { + // notify the tabbrowser that the tab state will be retrieved for the last time + // (so that extension authors can easily set data on soon-to-be-closed tabs) + var event = aWindow.document.createEvent("Events"); + event.initEvent("SSTabClosing", true, false); + aTab.dispatchEvent(event); + + // don't update our internal state if we don't have to + if (this._max_tabs_undo == 0) { + return; + } + + // Get the latest data for this tab (generally, from the cache) + let tabState = lazy.TabState.collect(aTab, TAB_CUSTOM_VALUES.get(aTab)); + + // Store closed-tab data for undo. + this.maybeSaveClosedTab(aWindow, aTab, tabState); + }, + + /** + * Flush and copy tab state when moving a tab to a new window. + * @param aFromBrowser + * Browser reference. + * @param aToBrowser + * Browser reference. + */ + onMoveToNewWindow(aFromBrowser, aToBrowser) { + lazy.TabStateFlusher.flush(aFromBrowser).then(() => { + let tabState = lazy.TabStateCache.get(aFromBrowser.permanentKey); + lazy.TabStateCache.update(aToBrowser.permanentKey, tabState); + }); + }, + + /** + * Save a closed tab if needed. + * @param aWindow + * Window reference. + * @param aTab + * Tab reference. + * @param tabState + * Tab state. + */ + maybeSaveClosedTab(aWindow, aTab, tabState) { + // Don't save private tabs + let isPrivateWindow = PrivateBrowsingUtils.isWindowPrivate(aWindow); + if (!isPrivateWindow && tabState.isPrivate) { + return; + } + if (aTab == aWindow.FirefoxViewHandler.tab) { + return; + } + + let permanentKey = aTab.linkedBrowser.permanentKey; + + let tabData = { + permanentKey, + state: tabState, + title: aTab.label, + image: aWindow.gBrowser.getIcon(aTab), + pos: aTab._tPos, + closedAt: Date.now(), + closedInGroup: aTab._closedInGroup, + }; + + let winData = this._windows[aWindow.__SSi]; + let closedTabs = winData._closedTabs; + + // Determine whether the tab contains any information worth saving. Note + // that there might be pending state changes queued in the child that + // didn't reach the parent yet. If a tab is emptied before closing then we + // might still remove it from the list of closed tabs later. + if (this._shouldSaveTabState(tabState)) { + // Save the tab state, for now. We might push a valid tab out + // of the list but those cases should be extremely rare and + // do probably never occur when using the browser normally. + // (Tests or add-ons might do weird things though.) + this.saveClosedTabData(winData, closedTabs, tabData); + } + + // Remember the closed tab to properly handle any last updates included in + // the final "update" message sent by the frame script's unload handler. + this._closedTabs.set(permanentKey, { winData, closedTabs, tabData }); + }, + + /** + * Remove listeners which were added when browser was inserted and reset restoring state. + * Also re-instate lazy data and basically revert tab to its lazy browser state. + * @param aTab + * Tab reference + */ + resetBrowserToLazyState(aTab) { + const gBrowser = aTab.ownerGlobal.gBrowser; + let browser = aTab.linkedBrowser; + // Browser is already lazy so don't do anything. + if (!browser.isConnected) { + return; + } + + this.cleanUpRemovedBrowser(aTab); + + aTab.setAttribute("pending", "true"); + + this._lastKnownFrameLoader.delete(browser.permanentKey); + this._crashedBrowsers.delete(browser.permanentKey); + aTab.removeAttribute("crashed"); + gBrowser.tabContainer.updateTabIndicatorAttr(aTab); + + let { userTypedValue = null, userTypedClear = 0 } = browser; + let hasStartedLoad = browser.didStartLoadSinceLastUserTyping(); + + let cacheState = lazy.TabStateCache.get(browser.permanentKey); + + // Cache the browser userTypedValue either if there is no cache state + // at all (e.g. if it was already discarded before we got to cache its state) + // or it may have been created but not including a userTypedValue (e.g. + // for a private tab we will cache `isPrivate: true` as soon as the tab + // is opened). + // + // But only if: + // + // - if there is no cache state yet (which is unfortunately required + // for tabs discarded immediately after creation by extensions, see + // Bug 1422588). + // + // - or the user typed value was already being loaded (otherwise the lazy + // tab will not be restored with the expected url once activated again, + // see Bug 1724205). + let shouldUpdateCacheState = + userTypedValue && + (!cacheState || (hasStartedLoad && !cacheState.userTypedValue)); + + if (shouldUpdateCacheState) { + // Discard was likely called before state can be cached. Update + // the persistent tab state cache with browser information so a + // restore will be successful. This information is necessary for + // restoreTabContent in ContentRestore.sys.mjs to work properly. + lazy.TabStateCache.update(browser.permanentKey, { + userTypedValue, + userTypedClear: 1, + }); + } + + TAB_LAZY_STATES.set(aTab, { + url: browser.currentURI.spec, + title: aTab.label, + userTypedValue, + userTypedClear, + }); + }, + + /** + * Check if we are dealing with a crashed browser. If so, then the corresponding + * crashed tab was revived by navigating to a different page. Remove the browser + * from the list of crashed browsers to stop ignoring its messages. + * @param aBrowser + * Browser reference + */ + maybeExitCrashedState(aBrowser) { + let uri = aBrowser.documentURI; + if (uri?.spec?.startsWith("about:tabcrashed")) { + this._crashedBrowsers.delete(aBrowser.permanentKey); + } + }, + + /** + * A debugging-only function to check if a browser is in _crashedBrowsers. + * @param aBrowser + * Browser reference + */ + isBrowserInCrashedSet(aBrowser) { + if (gDebuggingEnabled) { + return this._crashedBrowsers.has(aBrowser.permanentKey); + } + throw new Error( + "SessionStore.isBrowserInCrashedSet() should only be called in debug mode!" + ); + }, + + /** + * When a tab is removed or suspended, remove listeners and reset restoring state. + * @param aBrowser + * Browser reference + */ + cleanUpRemovedBrowser(aTab) { + let browser = aTab.linkedBrowser; + + browser.removeEventListener("SwapDocShells", this); + browser.removeEventListener("oop-browser-crashed", this); + browser.removeEventListener("oop-browser-buildid-mismatch", this); + + // If this tab was in the middle of restoring or still needs to be restored, + // we need to reset that state. If the tab was restoring, we will attempt to + // restore the next tab. + let previousState = TAB_STATE_FOR_BROWSER.get(browser); + if (previousState) { + this._resetTabRestoringState(aTab); + if (previousState == TAB_STATE_RESTORING) { + this.restoreNextTab(); + } + } + }, + + /** + * Insert a given |tabData| object into the list of |closedTabs|. We will + * determine the right insertion point based on the .closedAt properties of + * all tabs already in the list. The list will be truncated to contain a + * maximum of |this._max_tabs_undo| entries. + * + * @param winData (object) + * The data of the window. + * @param tabData (object) + * The tabData to be inserted. + * @param closedTabs (array) + * The list of closed tabs for a window. + */ + saveClosedTabData(winData, closedTabs, tabData) { + // Find the index of the first tab in the list + // of closed tabs that was closed before our tab. + let index = closedTabs.findIndex(tab => { + return tab.closedAt < tabData.closedAt; + }); + + // If we found no tab closed before our + // tab then just append it to the list. + if (index == -1) { + index = closedTabs.length; + } + + // About to save the closed tab, add a unique ID. + tabData.closedId = this._nextClosedId++; + + // Insert tabData at the right position. + closedTabs.splice(index, 0, tabData); + this._closedObjectsChanged = true; + + if (tabData.closedInGroup) { + if (winData._lastClosedTabGroupCount < this._max_tabs_undo) { + if (winData._lastClosedTabGroupCount < 0) { + winData._lastClosedTabGroupCount = 1; + } else { + winData._lastClosedTabGroupCount++; + } + } + } else { + winData._lastClosedTabGroupCount = -1; + } + + // Truncate the list of closed tabs, if needed. + if (closedTabs.length > this._max_tabs_undo) { + closedTabs.splice(this._max_tabs_undo, closedTabs.length); + } + }, + + /** + * Remove the closed tab data at |index| from the list of |closedTabs|. If + * the tab's final message is still pending we will simply discard it when + * it arrives so that the tab doesn't reappear in the list. + * + * @param winData (object) + * The data of the window. + * @param index (uint) + * The index of the tab to remove. + * @param closedTabs (array) + * The list of closed tabs for a window. + */ + removeClosedTabData(winData, closedTabs, index) { + // Remove the given index from the list. + let [closedTab] = closedTabs.splice(index, 1); + this._closedObjectsChanged = true; + + // If the tab is part of the last closed group, + // we need to deduct the tab from the count. + if (index < winData._lastClosedTabGroupCount) { + winData._lastClosedTabGroupCount--; + } + + // If the closed tab's state still has a .permanentKey property then we + // haven't seen its final update message yet. Remove it from the map of + // closed tabs so that we will simply discard its last messages and will + // not add it back to the list of closed tabs again. + if (closedTab.permanentKey) { + this._closedTabs.delete(closedTab.permanentKey); + this._closedWindowTabs.delete(closedTab.permanentKey); + delete closedTab.permanentKey; + } + + return closedTab; + }, + + /** + * When a tab is selected, save session data + * @param aWindow + * Window reference + */ + onTabSelect: function ssi_onTabSelect(aWindow) { + if (lazy.RunState.isRunning) { + this._windows[aWindow.__SSi].selected = + aWindow.gBrowser.tabContainer.selectedIndex; + + let tab = aWindow.gBrowser.selectedTab; + let browser = tab.linkedBrowser; + + if (TAB_STATE_FOR_BROWSER.get(browser) == TAB_STATE_NEEDS_RESTORE) { + // If BROWSER_STATE is still available for the browser and it is + // If __SS_restoreState is still on the browser and it is + // TAB_STATE_NEEDS_RESTORE, then we haven't restored this tab yet. + // + // It's possible that this tab was recently revived, and that + // we've deferred showing the tab crashed page for it (if the + // tab crashed in the background). If so, we need to re-enter + // the crashed state, since we'll be showing the tab crashed + // page. + if (lazy.TabCrashHandler.willShowCrashedTab(browser)) { + this.enterCrashedState(browser); + } else { + this.restoreTabContent(tab); + } + } + } + }, + + onTabShow: function ssi_onTabShow(aWindow, aTab) { + // If the tab hasn't been restored yet, move it into the right bucket + if ( + TAB_STATE_FOR_BROWSER.get(aTab.linkedBrowser) == TAB_STATE_NEEDS_RESTORE + ) { + TabRestoreQueue.hiddenToVisible(aTab); + + // let's kick off tab restoration again to ensure this tab gets restored + // with "restore_hidden_tabs" == false (now that it has become visible) + this.restoreNextTab(); + } + + // Default delay of 2 seconds gives enough time to catch multiple TabShow + // events. This used to be due to changing groups in 'tab groups'. We + // might be able to get rid of this now? + this.saveStateDelayed(aWindow); + }, + + onTabHide: function ssi_onTabHide(aWindow, aTab) { + // If the tab hasn't been restored yet, move it into the right bucket + if ( + TAB_STATE_FOR_BROWSER.get(aTab.linkedBrowser) == TAB_STATE_NEEDS_RESTORE + ) { + TabRestoreQueue.visibleToHidden(aTab); + } + + // Default delay of 2 seconds gives enough time to catch multiple TabHide + // events. This used to be due to changing groups in 'tab groups'. We + // might be able to get rid of this now? + this.saveStateDelayed(aWindow); + }, + + /** + * Handler for the event that is fired when a crashes. + * + * @param aWindow + * The window that the crashed browser belongs to. + * @param aBrowser + * The that is now in the crashed state. + */ + onBrowserCrashed(aBrowser) { + this.enterCrashedState(aBrowser); + // The browser crashed so we might never receive flush responses. + // Resolve all pending flush requests for the crashed browser. + lazy.TabStateFlusher.resolveAll(aBrowser); + }, + + /** + * Called when a browser is showing or is about to show the tab + * crashed page. This method causes SessionStore to ignore the + * tab until it's restored. + * + * @param browser + * The that is about to show the crashed page. + */ + enterCrashedState(browser) { + this._crashedBrowsers.add(browser.permanentKey); + + let win = browser.ownerGlobal; + + // If we hadn't yet restored, or were still in the midst of + // restoring this browser at the time of the crash, we need + // to reset its state so that we can try to restore it again + // when the user revives the tab from the crash. + if (TAB_STATE_FOR_BROWSER.has(browser)) { + let tab = win.gBrowser.getTabForBrowser(browser); + if (tab) { + this._resetLocalTabRestoringState(tab); + } + } + }, + + // Clean up data that has been closed a long time ago. + // Do not reschedule a save. This will wait for the next regular + // save. + onIdleDaily() { + // Remove old closed windows + this._cleanupOldData([this._closedWindows]); + + // Remove closed tabs of closed windows + this._cleanupOldData( + this._closedWindows.map(winData => winData._closedTabs) + ); + + // Remove closed tabs of open windows + this._cleanupOldData( + Object.keys(this._windows).map(key => this._windows[key]._closedTabs) + ); + + this._notifyOfClosedObjectsChange(); + }, + + // Remove "old" data from an array + _cleanupOldData(targets) { + const TIME_TO_LIVE = this._prefBranch.getIntPref( + "sessionstore.cleanup.forget_closed_after" + ); + const now = Date.now(); + + for (let array of targets) { + for (let i = array.length - 1; i >= 0; --i) { + let data = array[i]; + // Make sure that we have a timestamp to tell us when the target + // has been closed. If we don't have a timestamp, default to a + // safe timestamp: just now. + data.closedAt = data.closedAt || now; + if (now - data.closedAt > TIME_TO_LIVE) { + array.splice(i, 1); + this._closedObjectsChanged = true; + } + } + } + }, + + /* ........ nsISessionStore API .............. */ + + getBrowserState: function ssi_getBrowserState() { + let state = this.getCurrentState(); + + // Don't include the last session state in getBrowserState(). + delete state.lastSessionState; + + // Don't include any deferred initial state. + delete state.deferredInitialState; + + return JSON.stringify(state); + }, + + setBrowserState: function ssi_setBrowserState(aState) { + this._handleClosedWindows(); + + try { + var state = JSON.parse(aState); + } catch (ex) { + /* invalid state object - don't restore anything */ + } + if (!state) { + throw Components.Exception( + "Invalid state string: not JSON", + Cr.NS_ERROR_INVALID_ARG + ); + } + if (!state.windows) { + throw Components.Exception("No windows", Cr.NS_ERROR_INVALID_ARG); + } + + this._browserSetState = true; + + // Make sure the priority queue is emptied out + this._resetRestoringState(); + + var window = this._getTopWindow(); + if (!window) { + this._restoreCount = 1; + this._openWindowWithState(state); + return; + } + + // close all other browser windows + for (let otherWin of this._browserWindows) { + if (otherWin != window) { + otherWin.close(); + this.onClose(otherWin); + } + } + + // make sure closed window data isn't kept + if (this._closedWindows.length) { + this._closedWindows = []; + this._closedObjectsChanged = true; + } + + // determine how many windows are meant to be restored + this._restoreCount = state.windows ? state.windows.length : 0; + + // global data must be restored before restoreWindow is called so that + // it happens before observers are notified + this._globalState.setFromState(state); + + // Restore session cookies. + lazy.SessionCookies.restore(state.cookies || []); + + // restore to the given state + this.restoreWindows(window, state, { overwriteTabs: true }); + + // Notify of changes to closed objects. + this._notifyOfClosedObjectsChange(); + }, + + getWindowState: function ssi_getWindowState(aWindow) { + if ("__SSi" in aWindow) { + return Cu.cloneInto(this._getWindowState(aWindow), {}); + } + + if (DyingWindowCache.has(aWindow)) { + let data = DyingWindowCache.get(aWindow); + return Cu.cloneInto({ windows: [data] }, {}); + } + + throw Components.Exception( + "Window is not tracked", + Cr.NS_ERROR_INVALID_ARG + ); + }, + + setWindowState: function ssi_setWindowState(aWindow, aState, aOverwrite) { + if (!aWindow.__SSi) { + throw Components.Exception( + "Window is not tracked", + Cr.NS_ERROR_INVALID_ARG + ); + } + + this.restoreWindows(aWindow, aState, { overwriteTabs: aOverwrite }); + + // Notify of changes to closed objects. + this._notifyOfClosedObjectsChange(); + }, + + getTabState: function ssi_getTabState(aTab) { + if (!aTab || !aTab.ownerGlobal) { + throw Components.Exception("Need a valid tab", Cr.NS_ERROR_INVALID_ARG); + } + if (!aTab.ownerGlobal.__SSi) { + throw Components.Exception( + "Default view is not tracked", + Cr.NS_ERROR_INVALID_ARG + ); + } + + let tabState = lazy.TabState.collect(aTab, TAB_CUSTOM_VALUES.get(aTab)); + + return JSON.stringify(tabState); + }, + + setTabState(aTab, aState) { + // Remove the tab state from the cache. + // Note that we cannot simply replace the contents of the cache + // as |aState| can be an incomplete state that will be completed + // by |restoreTabs|. + let tabState = aState; + if (typeof tabState == "string") { + tabState = JSON.parse(aState); + } + if (!tabState) { + throw Components.Exception( + "Invalid state string: not JSON", + Cr.NS_ERROR_INVALID_ARG + ); + } + if (typeof tabState != "object") { + throw Components.Exception("Not an object", Cr.NS_ERROR_INVALID_ARG); + } + if (!("entries" in tabState)) { + throw Components.Exception( + "Invalid state object: no entries", + Cr.NS_ERROR_INVALID_ARG + ); + } + + let window = aTab.ownerGlobal; + if (!window || !("__SSi" in window)) { + throw Components.Exception( + "Window is not tracked", + Cr.NS_ERROR_INVALID_ARG + ); + } + + if (TAB_STATE_FOR_BROWSER.has(aTab.linkedBrowser)) { + this._resetTabRestoringState(aTab); + } + + this._ensureNoNullsInTabDataList( + window.gBrowser.tabs, + this._windows[window.__SSi].tabs, + aTab._tPos + ); + this.restoreTab(aTab, tabState); + + // Notify of changes to closed objects. + this._notifyOfClosedObjectsChange(); + }, + + getInternalObjectState(obj) { + if (obj.__SSi) { + return this._windows[obj.__SSi]; + } + return obj.loadURI + ? TAB_STATE_FOR_BROWSER.get(obj) + : TAB_CUSTOM_VALUES.get(obj); + }, + + duplicateTab: function ssi_duplicateTab( + aWindow, + aTab, + aDelta = 0, + aRestoreImmediately = true, + { inBackground, index } = {} + ) { + if (!aTab || !aTab.ownerGlobal) { + throw Components.Exception("Need a valid tab", Cr.NS_ERROR_INVALID_ARG); + } + if (!aTab.ownerGlobal.__SSi) { + throw Components.Exception( + "Default view is not tracked", + Cr.NS_ERROR_INVALID_ARG + ); + } + if (!aWindow.gBrowser) { + throw Components.Exception( + "Invalid window object: no gBrowser", + Cr.NS_ERROR_INVALID_ARG + ); + } + + // Create a new tab. + let userContextId = aTab.getAttribute("usercontextid"); + + let tabOptions = { + userContextId, + index, + ...(aTab == aWindow.gBrowser.selectedTab + ? { relatedToCurrent: true, ownerTab: aTab } + : {}), + skipLoad: true, + preferredRemoteType: aTab.linkedBrowser.remoteType, + }; + let newTab = aWindow.gBrowser.addTrustedTab(null, tabOptions); + + // Start the throbber to pretend we're doing something while actually + // waiting for data from the frame script. This throbber is disabled + // if the URI is a local about: URI. + let uriObj = aTab.linkedBrowser.currentURI; + if (!uriObj || (uriObj && !uriObj.schemeIs("about"))) { + newTab.setAttribute("busy", "true"); + } + + // Hack to ensure that the about:home, about:newtab, and about:welcome + // favicon is loaded instantaneously, to avoid flickering and improve + // perceived performance. + aWindow.gBrowser.setDefaultIcon(newTab, uriObj); + + // Collect state before flushing. + let tabState = lazy.TabState.collect(aTab, TAB_CUSTOM_VALUES.get(aTab)); + + // Flush to get the latest tab state to duplicate. + let browser = aTab.linkedBrowser; + lazy.TabStateFlusher.flush(browser).then(() => { + // The new tab might have been closed in the meantime. + if (newTab.closing || !newTab.linkedBrowser) { + return; + } + + let window = newTab.ownerGlobal; + + // The tab or its window might be gone. + if (!window || !window.__SSi) { + return; + } + + // Update state with flushed data. We can't use TabState.clone() here as + // the tab to duplicate may have already been closed. In that case we + // only have access to the . + let options = { includePrivateData: true }; + lazy.TabState.copyFromCache(browser.permanentKey, tabState, options); + + tabState.index += aDelta; + tabState.index = Math.max( + 1, + Math.min(tabState.index, tabState.entries.length) + ); + tabState.pinned = false; + + if (inBackground === false) { + aWindow.gBrowser.selectedTab = newTab; + } + + // Restore the state into the new tab. + this.restoreTab(newTab, tabState, { + restoreImmediately: aRestoreImmediately, + }); + }); + + return newTab; + }, + + getLastClosedTabCount(aWindow) { + if ("__SSi" in aWindow) { + return Math.min( + Math.max(this._windows[aWindow.__SSi]._lastClosedTabGroupCount, 1), + this.getClosedTabCountForWindow(aWindow) + ); + } + + throw (Components.returnCode = Cr.NS_ERROR_INVALID_ARG); + }, + + resetLastClosedTabCount(aWindow) { + if ("__SSi" in aWindow) { + this._windows[aWindow.__SSi]._lastClosedTabGroupCount = -1; + } else { + throw (Components.returnCode = Cr.NS_ERROR_INVALID_ARG); + } + }, + + getClosedTabCountForWindow: function ssi_getClosedTabCountForWindow(aWindow) { + if ("__SSi" in aWindow) { + return this._windows[aWindow.__SSi]._closedTabs.length; + } + + if (!DyingWindowCache.has(aWindow)) { + throw Components.Exception( + "Window is not tracked", + Cr.NS_ERROR_INVALID_ARG + ); + } + + return DyingWindowCache.get(aWindow)._closedTabs.length; + }, + + getClosedTabDataForWindow: function ssi_getClosedTabDataForWindow(aWindow) { + if ("__SSi" in aWindow) { + return Cu.cloneInto(this._windows[aWindow.__SSi]._closedTabs, {}); + } + + if (!DyingWindowCache.has(aWindow)) { + throw Components.Exception( + "Window is not tracked", + Cr.NS_ERROR_INVALID_ARG + ); + } + + let data = DyingWindowCache.get(aWindow); + return Cu.cloneInto(data._closedTabs, {}); + }, + + undoCloseTab: function ssi_undoCloseTab(aWindow, aIndex) { + if (!aWindow.__SSi) { + throw Components.Exception( + "Window is not tracked", + Cr.NS_ERROR_INVALID_ARG + ); + } + + let winData = this._windows[aWindow.__SSi]; + + // default to the most-recently closed tab + aIndex = aIndex || 0; + if (!(aIndex in winData._closedTabs)) { + throw Components.Exception( + "Invalid index: not in the closed tabs", + Cr.NS_ERROR_INVALID_ARG + ); + } + + // fetch the data of closed tab, while removing it from the array + let { state, pos } = this.removeClosedTabData( + winData, + winData._closedTabs, + aIndex + ); + + // Predict the remote type to use for the load to avoid unnecessary process + // switches. + let preferredRemoteType = lazy.E10SUtils.DEFAULT_REMOTE_TYPE; + if (state.entries?.length) { + let activeIndex = (state.index || state.entries.length) - 1; + activeIndex = Math.min(activeIndex, state.entries.length - 1); + activeIndex = Math.max(activeIndex, 0); + + preferredRemoteType = lazy.E10SUtils.getRemoteTypeForURI( + state.entries[activeIndex].url, + aWindow.gMultiProcessBrowser, + aWindow.gFissionBrowser, + lazy.E10SUtils.DEFAULT_REMOTE_TYPE, + null, + lazy.E10SUtils.predictOriginAttributes({ + window: aWindow, + userContextId: state.userContextId, + }) + ); + } + + // create a new tab + let tabbrowser = aWindow.gBrowser; + let tab = (tabbrowser.selectedTab = tabbrowser.addTrustedTab(null, { + index: pos, + pinned: state.pinned, + userContextId: state.userContextId, + skipLoad: true, + preferredRemoteType, + })); + + // restore tab content + this.restoreTab(tab, state); + + // Notify of changes to closed objects. + this._notifyOfClosedObjectsChange(); + + return tab; + }, + + forgetClosedTab: function ssi_forgetClosedTab(aWindow, aIndex) { + if (!aWindow.__SSi) { + throw Components.Exception( + "Window is not tracked", + Cr.NS_ERROR_INVALID_ARG + ); + } + + let winData = this._windows[aWindow.__SSi]; + + // default to the most-recently closed tab + aIndex = aIndex || 0; + if (!(aIndex in winData._closedTabs)) { + throw Components.Exception( + "Invalid index: not in the closed tabs", + Cr.NS_ERROR_INVALID_ARG + ); + } + + // remove closed tab from the array + this.removeClosedTabData(winData, winData._closedTabs, aIndex); + + // Notify of changes to closed objects. + this._notifyOfClosedObjectsChange(); + }, + + getClosedWindowCount: function ssi_getClosedWindowCount() { + return this._closedWindows.length; + }, + + getClosedWindowData: function ssi_getClosedWindowData() { + return Cu.cloneInto(this._closedWindows, {}); + }, + + maybeDontRestoreTabs(aWindow) { + // Don't restore the tabs if we restore the session at startup + this._windows[aWindow.__SSi]._maybeDontRestoreTabs = true; + }, + + isLastRestorableWindow() { + return ( + Object.values(this._windows).filter(winData => !winData.isPrivate) + .length == 1 && + !this._closedWindows.some(win => win._shouldRestore || false) + ); + }, + + undoCloseWindow: function ssi_undoCloseWindow(aIndex) { + if (!(aIndex in this._closedWindows)) { + throw Components.Exception( + "Invalid index: not in the closed windows", + Cr.NS_ERROR_INVALID_ARG + ); + } + // reopen the window + let state = { windows: this._removeClosedWindow(aIndex) }; + delete state.windows[0].closedAt; // Window is now open. + + let window = this._openWindowWithState(state); + this.windowToFocus = window; + WINDOW_SHOWING_PROMISES.get(window).promise.then(win => + this.restoreWindows(win, state, { overwriteTabs: true }) + ); + + // Notify of changes to closed objects. + this._notifyOfClosedObjectsChange(); + + return window; + }, + + forgetClosedWindow: function ssi_forgetClosedWindow(aIndex) { + // default to the most-recently closed window + aIndex = aIndex || 0; + if (!(aIndex in this._closedWindows)) { + throw Components.Exception( + "Invalid index: not in the closed windows", + Cr.NS_ERROR_INVALID_ARG + ); + } + + // remove closed window from the array + let winData = this._closedWindows[aIndex]; + this._removeClosedWindow(aIndex); + this._saveableClosedWindowData.delete(winData); + + // Notify of changes to closed objects. + this._notifyOfClosedObjectsChange(); + }, + + getCustomWindowValue(aWindow, aKey) { + if ("__SSi" in aWindow) { + let data = this._windows[aWindow.__SSi].extData || {}; + return data[aKey] || ""; + } + + if (DyingWindowCache.has(aWindow)) { + let data = DyingWindowCache.get(aWindow).extData || {}; + return data[aKey] || ""; + } + + throw Components.Exception( + "Window is not tracked", + Cr.NS_ERROR_INVALID_ARG + ); + }, + + setCustomWindowValue(aWindow, aKey, aStringValue) { + if (typeof aStringValue != "string") { + throw new TypeError("setCustomWindowValue only accepts string values"); + } + + if (!("__SSi" in aWindow)) { + throw Components.Exception( + "Window is not tracked", + Cr.NS_ERROR_INVALID_ARG + ); + } + if (!this._windows[aWindow.__SSi].extData) { + this._windows[aWindow.__SSi].extData = {}; + } + this._windows[aWindow.__SSi].extData[aKey] = aStringValue; + this.saveStateDelayed(aWindow); + }, + + deleteCustomWindowValue(aWindow, aKey) { + if ( + aWindow.__SSi && + this._windows[aWindow.__SSi].extData && + this._windows[aWindow.__SSi].extData[aKey] + ) { + delete this._windows[aWindow.__SSi].extData[aKey]; + } + this.saveStateDelayed(aWindow); + }, + + getCustomTabValue(aTab, aKey) { + return (TAB_CUSTOM_VALUES.get(aTab) || {})[aKey] || ""; + }, + + setCustomTabValue(aTab, aKey, aStringValue) { + if (typeof aStringValue != "string") { + throw new TypeError("setCustomTabValue only accepts string values"); + } + + // If the tab hasn't been restored, then set the data there, otherwise we + // could lose newly added data. + if (!TAB_CUSTOM_VALUES.has(aTab)) { + TAB_CUSTOM_VALUES.set(aTab, {}); + } + + TAB_CUSTOM_VALUES.get(aTab)[aKey] = aStringValue; + this.saveStateDelayed(aTab.ownerGlobal); + }, + + deleteCustomTabValue(aTab, aKey) { + let state = TAB_CUSTOM_VALUES.get(aTab); + if (state && aKey in state) { + delete state[aKey]; + this.saveStateDelayed(aTab.ownerGlobal); + } + }, + + /** + * Retrieves data specific to lazy-browser tabs. If tab is not lazy, + * will return undefined. + * + * @param aTab (xul:tab) + * The tabbrowser-tab the data is for. + * @param aKey (string) + * The key which maps to the desired data. + */ + getLazyTabValue(aTab, aKey) { + return (TAB_LAZY_STATES.get(aTab) || {})[aKey]; + }, + + getCustomGlobalValue(aKey) { + return this._globalState.get(aKey); + }, + + setCustomGlobalValue(aKey, aStringValue) { + if (typeof aStringValue != "string") { + throw new TypeError("setCustomGlobalValue only accepts string values"); + } + + this._globalState.set(aKey, aStringValue); + this.saveStateDelayed(); + }, + + deleteCustomGlobalValue(aKey) { + this._globalState.delete(aKey); + this.saveStateDelayed(); + }, + + persistTabAttribute: function ssi_persistTabAttribute(aName) { + if (lazy.TabAttributes.persist(aName)) { + this.saveStateDelayed(); + } + }, + + /** + * Undoes the closing of a tab or window which corresponds + * to the closedId passed in. + * + * @param aClosedId + * The closedId of the tab or window + * @param aIncludePrivate + * Whether to restore private tabs or windows + * + * @returns a tab or window object + */ + undoCloseById(aClosedId, aIncludePrivate = true) { + // Check for a window first. + for (let i = 0, l = this._closedWindows.length; i < l; i++) { + if (this._closedWindows[i].closedId == aClosedId) { + return this.undoCloseWindow(i); + } + } + + // Check for a tab. + for (let window of Services.wm.getEnumerator("navigator:browser")) { + if (!aIncludePrivate && PrivateBrowsingUtils.isWindowPrivate(window)) { + continue; + } + let windowState = this._windows[window.__SSi]; + if (windowState) { + for (let j = 0, l = windowState._closedTabs.length; j < l; j++) { + if (windowState._closedTabs[j].closedId == aClosedId) { + return this.undoCloseTab(window, j); + } + } + } + } + + // Neither a tab nor a window was found, return undefined and let the caller decide what to do about it. + return undefined; + }, + + /** + * Updates the label and icon for a using the data from + * tabData. + * + * @param tab + * The to update. + * @param tabData (optional) + * The tabData to use to update the tab. If the argument is + * not supplied, the data will be retrieved from the cache. + */ + updateTabLabelAndIcon(tab, tabData = null) { + if (tab.hasAttribute("customizemode")) { + return; + } + + let browser = tab.linkedBrowser; + let win = browser.ownerGlobal; + + if (!tabData) { + tabData = lazy.TabState.collect(tab, TAB_CUSTOM_VALUES.get(tab)); + if (!tabData) { + throw new Error("tabData not found for given tab"); + } + } + + let activePageData = tabData.entries[tabData.index - 1] || null; + + // If the page has a title, set it. + if (activePageData) { + if (activePageData.title && activePageData.title != activePageData.url) { + win.gBrowser.setInitialTabTitle(tab, activePageData.title, { + isContentTitle: true, + }); + } else { + win.gBrowser.setInitialTabTitle(tab, activePageData.url); + } + } + + // Restore the tab icon. + if ("image" in tabData) { + // We know that about:blank is safe to load in any remote type. Since + // SessionStore is triggered with about:blank, there must be a process + // flip. We will ignore the first about:blank load to prevent resetting the + // favicon that we have set earlier to avoid flickering and improve + // perceived performance. + if ( + !activePageData || + (activePageData && activePageData.url != "about:blank") + ) { + win.gBrowser.setIcon( + tab, + tabData.image, + undefined, + tabData.iconLoadingPrincipal + ); + } + lazy.TabStateCache.update(browser.permanentKey, { + image: null, + iconLoadingPrincipal: null, + }); + } + }, + + // This method deletes all the closedTabs matching userContextId. + _forgetTabsWithUserContextId(userContextId) { + for (let window of Services.wm.getEnumerator("navigator:browser")) { + let windowState = this._windows[window.__SSi]; + if (windowState) { + // In order to remove the tabs in the correct order, we store the + // indexes, into an array, then we revert the array and remove closed + // data from the last one going backward. + let indexes = []; + windowState._closedTabs.forEach((closedTab, index) => { + if (closedTab.state.userContextId == userContextId) { + indexes.push(index); + } + }); + + for (let index of indexes.reverse()) { + this.removeClosedTabData(windowState, windowState._closedTabs, index); + } + } + } + + // Notify of changes to closed objects. + this._notifyOfClosedObjectsChange(); + }, + + /** + * Restores the session state stored in LastSession. This will attempt + * to merge data into the current session. If a window was opened at startup + * with pinned tab(s), then the remaining data from the previous session for + * that window will be opened into that window. Otherwise new windows will + * be opened. + */ + restoreLastSession: function ssi_restoreLastSession() { + // Use the public getter since it also checks PB mode + if (!this.canRestoreLastSession) { + throw Components.Exception("Last session can not be restored"); + } + + Services.obs.notifyObservers(null, NOTIFY_INITIATING_MANUAL_RESTORE); + + // First collect each window with its id... + let windows = {}; + for (let window of this._browserWindows) { + if (window.__SS_lastSessionWindowID) { + windows[window.__SS_lastSessionWindowID] = window; + } + } + + let lastSessionState = LastSession.getState(); + + // This shouldn't ever be the case... + if (!lastSessionState.windows.length) { + throw Components.Exception( + "lastSessionState has no windows", + Cr.NS_ERROR_UNEXPECTED + ); + } + + // We're technically doing a restore, so set things up so we send the + // notification when we're done. We want to send "sessionstore-browser-state-restored". + this._restoreCount = lastSessionState.windows.length; + this._browserSetState = true; + + // We want to re-use the last opened window instead of opening a new one in + // the case where it's "empty" and not associated with a window in the session. + // We will do more processing via _prepWindowToRestoreInto if we need to use + // the lastWindow. + let lastWindow = this._getTopWindow(); + let canUseLastWindow = lastWindow && !lastWindow.__SS_lastSessionWindowID; + + // global data must be restored before restoreWindow is called so that + // it happens before observers are notified + this._globalState.setFromState(lastSessionState); + + let openWindows = []; + let windowsToOpen = []; + + // Restore session cookies. + lazy.SessionCookies.restore(lastSessionState.cookies || []); + + // Restore into windows or open new ones as needed. + for (let i = 0; i < lastSessionState.windows.length; i++) { + let winState = lastSessionState.windows[i]; + let lastSessionWindowID = winState.__lastSessionWindowID; + // delete lastSessionWindowID so we don't add that to the window again + delete winState.__lastSessionWindowID; + + // See if we can use an open window. First try one that is associated with + // the state we're trying to restore and then fallback to the last selected + // window. + let windowToUse = windows[lastSessionWindowID]; + if (!windowToUse && canUseLastWindow) { + windowToUse = lastWindow; + canUseLastWindow = false; + } + + let [canUseWindow, canOverwriteTabs] = + this._prepWindowToRestoreInto(windowToUse); + + // If there's a window already open that we can restore into, use that + if (canUseWindow) { + // Since we're not overwriting existing tabs, we want to merge _closedTabs, + // putting existing ones first. Then make sure we're respecting the max pref. + if (winState._closedTabs && winState._closedTabs.length) { + let curWinState = this._windows[windowToUse.__SSi]; + curWinState._closedTabs = curWinState._closedTabs.concat( + winState._closedTabs + ); + curWinState._closedTabs.splice( + this._max_tabs_undo, + curWinState._closedTabs.length + ); + } + + // XXXzpao This is going to merge extData together (taking what was in + // winState over what is in the window already. + // We don't restore window right away, just store its data. + // Later, these windows will be restored with newly opened windows. + this._updateWindowRestoreState(windowToUse, { + windows: [winState], + options: { overwriteTabs: canOverwriteTabs }, + }); + openWindows.push(windowToUse); + } else { + windowsToOpen.push(winState); + } + } + + // Actually restore windows in reversed z-order. + this._openWindows({ windows: windowsToOpen }).then(openedWindows => + this._restoreWindowsInReversedZOrder(openWindows.concat(openedWindows)) + ); + + // Merge closed windows from this session with ones from last session + if (lastSessionState._closedWindows) { + this._closedWindows = this._closedWindows.concat( + lastSessionState._closedWindows + ); + this._capClosedWindows(); + this._closedObjectsChanged = true; + } + + lazy.DevToolsShim.restoreDevToolsSession(lastSessionState); + + // Set data that persists between sessions + this._recentCrashes = + (lastSessionState.session && lastSessionState.session.recentCrashes) || 0; + + // Update the session start time using the restored session state. + this._updateSessionStartTime(lastSessionState); + + LastSession.clear(); + + // Notify of changes to closed objects. + this._notifyOfClosedObjectsChange(); + }, + + /** + * Revive a crashed tab and restore its state from before it crashed. + * + * @param aTab + * A linked to a crashed browser. This is a no-op if the + * browser hasn't actually crashed, or is not associated with a tab. + * This function will also throw if the browser happens to be remote. + */ + reviveCrashedTab(aTab) { + if (!aTab) { + throw new Error( + "SessionStore.reviveCrashedTab expected a tab, but got null." + ); + } + + const gBrowser = aTab.ownerGlobal.gBrowser; + let browser = aTab.linkedBrowser; + if (!this._crashedBrowsers.has(browser.permanentKey)) { + return; + } + + // Sanity check - the browser to be revived should not be remote + // at this point. + if (browser.isRemoteBrowser) { + throw new Error( + "SessionStore.reviveCrashedTab: " + + "Somehow a crashed browser is still remote." + ); + } + + // We put the browser at about:blank in case the user is + // restoring tabs on demand. This way, the user won't see + // a flash of the about:tabcrashed page after selecting + // the revived tab. + aTab.removeAttribute("crashed"); + gBrowser.tabContainer.updateTabIndicatorAttr(aTab); + + browser.loadURI(lazy.blankURI, { + triggeringPrincipal: Services.scriptSecurityManager.createNullPrincipal({ + userContextId: aTab.userContextId, + }), + remoteTypeOverride: lazy.E10SUtils.NOT_REMOTE, + }); + + let data = lazy.TabState.collect(aTab, TAB_CUSTOM_VALUES.get(aTab)); + this.restoreTab(aTab, data, { + forceOnDemand: true, + }); + }, + + /** + * Revive all crashed tabs and reset the crashed tabs count to 0. + */ + reviveAllCrashedTabs() { + for (let window of Services.wm.getEnumerator("navigator:browser")) { + for (let tab of window.gBrowser.tabs) { + this.reviveCrashedTab(tab); + } + } + }, + + /** + * Retrieves the latest session history information for a tab. The cached data + * is returned immediately, but a callback may be provided that supplies + * up-to-date data when or if it is available. The callback is passed a single + * argument with data in the same format as the return value. + * + * @param tab tab to retrieve the session history for + * @param updatedCallback function to call with updated data as the single argument + * @returns a object containing 'index' specifying the current index, and an + * array 'entries' containing an object for each history item. + */ + getSessionHistory(tab, updatedCallback) { + if (updatedCallback) { + lazy.TabStateFlusher.flush(tab.linkedBrowser).then(() => { + let sessionHistory = this.getSessionHistory(tab); + if (sessionHistory) { + updatedCallback(sessionHistory); + } + }); + } + + // Don't continue if the tab was closed before TabStateFlusher.flush resolves. + if (tab.linkedBrowser) { + let tabState = lazy.TabState.collect(tab, TAB_CUSTOM_VALUES.get(tab)); + return { index: tabState.index - 1, entries: tabState.entries }; + } + return null; + }, + + /** + * See if aWindow is usable for use when restoring a previous session via + * restoreLastSession. If usable, prepare it for use. + * + * @param aWindow + * the window to inspect & prepare + * @returns [canUseWindow, canOverwriteTabs] + * canUseWindow: can the window be used to restore into + * canOverwriteTabs: all of the current tabs are home pages and we + * can overwrite them + */ + _prepWindowToRestoreInto: function ssi_prepWindowToRestoreInto(aWindow) { + if (!aWindow) { + return [false, false]; + } + + // We might be able to overwrite the existing tabs instead of just adding + // the previous session's tabs to the end. This will be set if possible. + let canOverwriteTabs = false; + + // Look at the open tabs in comparison to home pages. If all the tabs are + // home pages then we'll end up overwriting all of them. Otherwise we'll + // just close the tabs that match home pages. Tabs with the about:blank + // URI will always be overwritten. + let homePages = ["about:blank"]; + let removableTabs = []; + let tabbrowser = aWindow.gBrowser; + let startupPref = this._prefBranch.getIntPref("startup.page"); + if (startupPref == 1) { + homePages = homePages.concat(lazy.HomePage.get(aWindow).split("|")); + } + + for (let i = tabbrowser._numPinnedTabs; i < tabbrowser.tabs.length; i++) { + let tab = tabbrowser.tabs[i]; + if (homePages.includes(tab.linkedBrowser.currentURI.spec)) { + removableTabs.push(tab); + } + } + + if ( + tabbrowser.tabs.length > tabbrowser.visibleTabs.length && + tabbrowser.visibleTabs.length === removableTabs.length + ) { + // If all the visible tabs are also removable and the selected tab is hidden or removeable, we will later remove + // all "removable" tabs causing the browser to automatically close because the only tab left is hidden. + // To prevent the browser from automatically closing, we will leave one other visible tab open. + removableTabs.shift(); + } + + if (tabbrowser.tabs.length == removableTabs.length) { + canOverwriteTabs = true; + } else { + // If we're not overwriting all of the tabs, then close the home tabs. + for (let i = removableTabs.length - 1; i >= 0; i--) { + tabbrowser.removeTab(removableTabs.pop(), { animate: false }); + } + } + + return [true, canOverwriteTabs]; + }, + + /* ........ Saving Functionality .............. */ + + /** + * Store window dimensions, visibility, sidebar + * @param aWindow + * Window reference + */ + _updateWindowFeatures: function ssi_updateWindowFeatures(aWindow) { + var winData = this._windows[aWindow.__SSi]; + + WINDOW_ATTRIBUTES.forEach(function (aAttr) { + winData[aAttr] = this._getWindowDimension(aWindow, aAttr); + }, this); + + if (winData.sizemode != "minimized") { + winData.sizemodeBeforeMinimized = winData.sizemode; + } + + var hidden = WINDOW_HIDEABLE_FEATURES.filter(function (aItem) { + return aWindow[aItem] && !aWindow[aItem].visible; + }); + if (hidden.length) { + winData.hidden = hidden.join(","); + } else if (winData.hidden) { + delete winData.hidden; + } + + let sidebarBox = aWindow.document.getElementById("sidebar-box"); + let sidebar = sidebarBox.getAttribute("sidebarcommand"); + if (sidebar && sidebarBox.getAttribute("checked") == "true") { + winData.sidebar = sidebar; + } else if (winData.sidebar) { + delete winData.sidebar; + } + let workspaceID = aWindow.getWorkspaceID(); + if (workspaceID) { + winData.workspaceID = workspaceID; + } + }, + + /** + * gather session data as object + * @param aUpdateAll + * Bool update all windows + * @returns object + */ + getCurrentState(aUpdateAll) { + this._handleClosedWindows().then(() => { + this._notifyOfClosedObjectsChange(); + }); + + var activeWindow = this._getTopWindow(); + + TelemetryStopwatch.start("FX_SESSION_RESTORE_COLLECT_ALL_WINDOWS_DATA_MS"); + if (lazy.RunState.isRunning) { + // update the data for all windows with activities since the last save operation. + let index = 0; + for (let window of this._orderedBrowserWindows) { + if (!this._isWindowLoaded(window)) { + // window data is still in _statesToRestore + continue; + } + if (aUpdateAll || DirtyWindows.has(window) || window == activeWindow) { + this._collectWindowData(window); + } else { + // always update the window features (whose change alone never triggers a save operation) + this._updateWindowFeatures(window); + } + this._windows[window.__SSi].zIndex = ++index; + } + DirtyWindows.clear(); + } + TelemetryStopwatch.finish("FX_SESSION_RESTORE_COLLECT_ALL_WINDOWS_DATA_MS"); + + // An array that at the end will hold all current window data. + var total = []; + // The ids of all windows contained in 'total' in the same order. + var ids = []; + // The number of window that are _not_ popups. + var nonPopupCount = 0; + var ix; + + // collect the data for all windows + for (ix in this._windows) { + if (this._windows[ix]._restoring) { + // window data is still in _statesToRestore + continue; + } + total.push(this._windows[ix]); + ids.push(ix); + if (!this._windows[ix].isPopup) { + nonPopupCount++; + } + } + + // collect the data for all windows yet to be restored + for (ix in this._statesToRestore) { + for (let winData of this._statesToRestore[ix].windows) { + total.push(winData); + if (!winData.isPopup) { + nonPopupCount++; + } + } + } + + // shallow copy this._closedWindows to preserve current state + let lastClosedWindowsCopy = this._closedWindows.slice(); + + if (AppConstants.platform != "macosx") { + // If no non-popup browser window remains open, return the state of the last + // closed window(s). We only want to do this when we're actually "ending" + // the session. + // XXXzpao We should do this for _restoreLastWindow == true, but that has + // its own check for popups. c.f. bug 597619 + if ( + nonPopupCount == 0 && + !!lastClosedWindowsCopy.length && + lazy.RunState.isQuitting + ) { + // prepend the last non-popup browser window, so that if the user loads more tabs + // at startup we don't accidentally add them to a popup window + do { + total.unshift(lastClosedWindowsCopy.shift()); + } while (total[0].isPopup && lastClosedWindowsCopy.length); + } + } + + if (activeWindow) { + this.activeWindowSSiCache = activeWindow.__SSi || ""; + } + ix = ids.indexOf(this.activeWindowSSiCache); + // We don't want to restore focus to a minimized window or a window which had all its + // tabs stripped out (doesn't exist). + if (ix != -1 && total[ix] && total[ix].sizemode == "minimized") { + ix = -1; + } + + let session = { + lastUpdate: Date.now(), + startTime: this._sessionStartTime, + recentCrashes: this._recentCrashes, + }; + + let state = { + version: ["sessionrestore", FORMAT_VERSION], + windows: total, + selectedWindow: ix + 1, + _closedWindows: lastClosedWindowsCopy, + session, + global: this._globalState.getState(), + }; + + // Collect and store session cookies. + state.cookies = lazy.SessionCookies.collect(); + + lazy.DevToolsShim.saveDevToolsSession(state); + + // Persist the last session if we deferred restoring it + if (LastSession.canRestore) { + state.lastSessionState = LastSession.getState(); + } + + // If we were called by the SessionSaver and started with only a private + // window we want to pass the deferred initial state to not lose the + // previous session. + if (this._deferredInitialState) { + state.deferredInitialState = this._deferredInitialState; + } + + return state; + }, + + /** + * serialize session data for a window + * @param aWindow + * Window reference + * @returns string + */ + _getWindowState: function ssi_getWindowState(aWindow) { + if (!this._isWindowLoaded(aWindow)) { + return this._statesToRestore[WINDOW_RESTORE_IDS.get(aWindow)]; + } + + if (lazy.RunState.isRunning) { + this._collectWindowData(aWindow); + } + + return { windows: [this._windows[aWindow.__SSi]] }; + }, + + /** + * Gathers data about a window and its tabs, and updates its + * entry in this._windows. + * + * @param aWindow + * Window references. + * @returns a Map mapping the browser tabs from aWindow to the tab + * entry that was put into the window data in this._windows. + */ + _collectWindowData: function ssi_collectWindowData(aWindow) { + let tabMap = new Map(); + + if (!this._isWindowLoaded(aWindow)) { + return tabMap; + } + + let tabbrowser = aWindow.gBrowser; + let tabs = tabbrowser.tabs; + let winData = this._windows[aWindow.__SSi]; + let tabsData = (winData.tabs = []); + + // update the internal state data for this window + for (let tab of tabs) { + if (tab == aWindow.FirefoxViewHandler.tab) { + continue; + } + let tabData = lazy.TabState.collect(tab, TAB_CUSTOM_VALUES.get(tab)); + tabMap.set(tab, tabData); + tabsData.push(tabData); + } + + let selectedIndex = tabbrowser.tabbox.selectedIndex + 1; + // We don't store the Firefox View tab in Session Store, so if it was the last selected "tab" when + // a window is closed, point to the first item in the tab strip instead (it will never be the Firefox View tab, + // since it's only inserted into the tab strip after it's selected). + if (aWindow.FirefoxViewHandler.tab?.selected) { + selectedIndex = 1; + winData.title = tabbrowser.tabs[0].label; + } + winData.selected = selectedIndex; + + this._updateWindowFeatures(aWindow); + + // Make sure we keep __SS_lastSessionWindowID around for cases like entering + // or leaving PB mode. + if (aWindow.__SS_lastSessionWindowID) { + this._windows[aWindow.__SSi].__lastSessionWindowID = + aWindow.__SS_lastSessionWindowID; + } + + DirtyWindows.remove(aWindow); + return tabMap; + }, + + /* ........ Restoring Functionality .............. */ + + /** + * Open windows with data + * + * @param root + * Windows data + * @returns a promise resolved when all windows have been opened + */ + _openWindows(root) { + let windowsOpened = []; + for (let winData of root.windows) { + if (!winData || !winData.tabs || !winData.tabs[0]) { + continue; + } + windowsOpened.push(this._openWindowWithState({ windows: [winData] })); + } + let windowOpenedPromises = []; + for (const openedWindow of windowsOpened) { + let deferred = WINDOW_SHOWING_PROMISES.get(openedWindow); + windowOpenedPromises.push(deferred.promise); + } + return Promise.all(windowOpenedPromises); + }, + + /** reset closedId's from previous sessions to ensure these IDs are unique + * @param tabData + * an array of data to be restored + * @returns the updated tabData array + */ + _resetClosedIds(tabData) { + for (let entry of tabData) { + entry.closedId = this._nextClosedId++; + } + return tabData; + }, + /** + * restore features to a single window + * @param aWindow + * Window reference to the window to use for restoration + * @param winData + * JS object + * @param aOptions.overwriteTabs + * to overwrite existing tabs w/ new ones + * @param aOptions.firstWindow + * if this is the first non-private window we're + * restoring in this session, that might open an + * external link as well + */ + restoreWindow: function ssi_restoreWindow(aWindow, winData, aOptions = {}) { + let overwriteTabs = aOptions && aOptions.overwriteTabs; + let firstWindow = aOptions && aOptions.firstWindow; + + // initialize window if necessary + if (aWindow && (!aWindow.__SSi || !this._windows[aWindow.__SSi])) { + this.onLoad(aWindow); + } + + TelemetryStopwatch.start("FX_SESSION_RESTORE_RESTORE_WINDOW_MS"); + + // We're not returning from this before we end up calling restoreTabs + // for this window, so make sure we send the SSWindowStateBusy event. + this._sendWindowRestoringNotification(aWindow); + this._setWindowStateBusy(aWindow); + + if (winData.workspaceID) { + aWindow.moveToWorkspace(winData.workspaceID); + } + + if (!winData.tabs) { + winData.tabs = []; + // don't restore a single blank tab when we've had an external + // URL passed in for loading at startup (cf. bug 357419) + } else if ( + firstWindow && + !overwriteTabs && + winData.tabs.length == 1 && + (!winData.tabs[0].entries || !winData.tabs[0].entries.length) + ) { + winData.tabs = []; + } + + // See SessionStoreInternal.restoreTabs for a description of what + // selectTab represents. + let selectTab = 0; + if (overwriteTabs) { + selectTab = parseInt(winData.selected || 1, 10); + selectTab = Math.max(selectTab, 1); + selectTab = Math.min(selectTab, winData.tabs.length); + } + + let tabbrowser = aWindow.gBrowser; + + // disable smooth scrolling while adding, moving, removing and selecting tabs + let arrowScrollbox = tabbrowser.tabContainer.arrowScrollbox; + let smoothScroll = arrowScrollbox.smoothScroll; + arrowScrollbox.smoothScroll = false; + + // We need to keep track of the initially open tabs so that they + // can be moved to the end of the restored tabs. + let initialTabs; + if (!overwriteTabs && firstWindow) { + initialTabs = Array.from(tabbrowser.tabs); + } + + // Get rid of tabs that aren't needed anymore. + if (overwriteTabs) { + for (let i = tabbrowser.browsers.length - 1; i >= 0; i--) { + if (!tabbrowser.tabs[i].selected) { + tabbrowser.removeTab(tabbrowser.tabs[i]); + } + } + } + + let restoreTabsLazily = + this._prefBranch.getBoolPref("sessionstore.restore_tabs_lazily") && + this._restore_on_demand; + + if (winData.tabs.length) { + var tabs = tabbrowser.createTabsForSessionRestore( + restoreTabsLazily, + selectTab, + winData.tabs + ); + } + + // Move the originally open tabs to the end. + if (initialTabs) { + let endPosition = tabbrowser.tabs.length - 1; + for (let i = 0; i < initialTabs.length; i++) { + tabbrowser.unpinTab(initialTabs[i]); + tabbrowser.moveTabTo(initialTabs[i], endPosition); + } + } + + // We want to correlate the window with data from the last session, so + // assign another id if we have one. Otherwise clear so we don't do + // anything with it. + delete aWindow.__SS_lastSessionWindowID; + if (winData.__lastSessionWindowID) { + aWindow.__SS_lastSessionWindowID = winData.__lastSessionWindowID; + } + + if (overwriteTabs) { + delete this._windows[aWindow.__SSi].extData; + } + + // Restore cookies from legacy sessions, i.e. before bug 912717. + lazy.SessionCookies.restore(winData.cookies || []); + + if (winData.extData) { + if (!this._windows[aWindow.__SSi].extData) { + this._windows[aWindow.__SSi].extData = {}; + } + for (var key in winData.extData) { + this._windows[aWindow.__SSi].extData[key] = winData.extData[key]; + } + } + + let newClosedTabsData = winData._closedTabs || []; + newClosedTabsData = this._resetClosedIds(newClosedTabsData); + + let newLastClosedTabGroupCount = winData._lastClosedTabGroupCount || -1; + + if (overwriteTabs || firstWindow) { + // Overwrite existing closed tabs data when overwriteTabs=true + // or we're the first window to be restored. + this._windows[aWindow.__SSi]._closedTabs = newClosedTabsData; + } else if (this._max_tabs_undo > 0) { + // If we merge tabs, we also want to merge closed tabs data. We'll assume + // the restored tabs were closed more recently and append the current list + // of closed tabs to the new one... + newClosedTabsData = newClosedTabsData.concat( + this._windows[aWindow.__SSi]._closedTabs + ); + + // ... and make sure that we don't exceed the max number of closed tabs + // we can restore. + this._windows[aWindow.__SSi]._closedTabs = newClosedTabsData.slice( + 0, + this._max_tabs_undo + ); + } + // Because newClosedTabsData are put in first, we need to + // copy also the _lastClosedTabGroupCount. + this._windows[aWindow.__SSi]._lastClosedTabGroupCount = + newLastClosedTabGroupCount; + + if (!this._isWindowLoaded(aWindow)) { + // from now on, the data will come from the actual window + delete this._statesToRestore[WINDOW_RESTORE_IDS.get(aWindow)]; + WINDOW_RESTORE_IDS.delete(aWindow); + delete this._windows[aWindow.__SSi]._restoring; + } + + // Restore tabs, if any. + if (winData.tabs.length) { + this.restoreTabs(aWindow, tabs, winData.tabs, selectTab); + } + + // set smoothScroll back to the original value + arrowScrollbox.smoothScroll = smoothScroll; + + TelemetryStopwatch.finish("FX_SESSION_RESTORE_RESTORE_WINDOW_MS"); + + this._setWindowStateReady(aWindow); + + this._sendWindowRestoredNotification(aWindow); + + Services.obs.notifyObservers(aWindow, NOTIFY_SINGLE_WINDOW_RESTORED); + + this._sendRestoreCompletedNotifications(); + }, + + /** + * Prepare connection to host beforehand. + * + * @param tab + * Tab we are loading from. + * @param url + * URL of a host. + * @returns a flag indicates whether a connection has been made + */ + prepareConnectionToHost(tab, url) { + if (url && !url.startsWith("about:")) { + let principal = Services.scriptSecurityManager.createNullPrincipal({ + userContextId: tab.userContextId, + }); + let sc = Services.io.QueryInterface(Ci.nsISpeculativeConnect); + let uri = Services.io.newURI(url); + try { + sc.speculativeConnect(uri, principal, null, false); + return true; + } catch (error) { + // Can't setup speculative connection for this url. + console.error(error); + return false; + } + } + return false; + }, + + /** + * Make a connection to a host when users hover mouse on a tab. + * This will also set a flag in the tab to prevent us from speculatively + * connecting a second time. + * + * @param tab + * a tab to speculatively connect on mouse hover. + */ + speculativeConnectOnTabHover(tab) { + let tabState = TAB_LAZY_STATES.get(tab); + if (tabState && !tabState.connectionPrepared) { + let url = this.getLazyTabValue(tab, "url"); + let prepared = this.prepareConnectionToHost(tab, url); + // This is used to test if a connection has been made beforehand. + if (gDebuggingEnabled) { + tab.__test_connection_prepared = prepared; + tab.__test_connection_url = url; + } + // A flag indicate that we've prepared a connection for this tab and + // if is called again, we shouldn't prepare another connection. + tabState.connectionPrepared = true; + } + }, + + /** + * This function will restore window features and then retore window data. + * + * @param windows + * ordered array of windows to restore + */ + _restoreWindowsFeaturesAndTabs(windows) { + // First, we restore window features, so that when users start interacting + // with a window, we don't steal the window focus. + for (let window of windows) { + let state = this._statesToRestore[WINDOW_RESTORE_IDS.get(window)]; + this.restoreWindowFeatures(window, state.windows[0]); + } + + // Then we restore data into windows. + for (let window of windows) { + let state = this._statesToRestore[WINDOW_RESTORE_IDS.get(window)]; + this.restoreWindow( + window, + state.windows[0], + state.options || { overwriteTabs: true } + ); + WINDOW_RESTORE_ZINDICES.delete(window); + } + }, + + /** + * This function will restore window in reversed z-index, so that users will + * be presented with most recently used window first. + * + * @param windows + * unordered array of windows to restore + */ + _restoreWindowsInReversedZOrder(windows) { + windows.sort( + (a, b) => + (WINDOW_RESTORE_ZINDICES.get(a) || 0) - + (WINDOW_RESTORE_ZINDICES.get(b) || 0) + ); + + this.windowToFocus = windows[0]; + this._restoreWindowsFeaturesAndTabs(windows); + }, + + /** + * Restore multiple windows using the provided state. + * @param aWindow + * Window reference to the first window to use for restoration. + * Additionally required windows will be opened. + * @param aState + * JS object or JSON string + * @param aOptions.overwriteTabs + * to overwrite existing tabs w/ new ones + * @param aOptions.firstWindow + * if this is the first non-private window we're + * restoring in this session, that might open an + * external link as well + */ + restoreWindows: function ssi_restoreWindows(aWindow, aState, aOptions = {}) { + // initialize window if necessary + if (aWindow && (!aWindow.__SSi || !this._windows[aWindow.__SSi])) { + this.onLoad(aWindow); + } + + let root; + try { + root = typeof aState == "string" ? JSON.parse(aState) : aState; + } catch (ex) { + // invalid state object - don't restore anything + this._log.error(ex); + this._sendRestoreCompletedNotifications(); + return; + } + + // Restore closed windows if any. + if (root._closedWindows) { + this._closedWindows = root._closedWindows; + this._closedObjectsChanged = true; + } + + // We're done here if there are no windows. + if (!root.windows || !root.windows.length) { + this._sendRestoreCompletedNotifications(); + return; + } + + let firstWindowData = root.windows.splice(0, 1); + // Store the restore state and restore option of the current window, + // so that the window can be restored in reversed z-order. + this._updateWindowRestoreState(aWindow, { + windows: firstWindowData, + options: aOptions, + }); + + // Begin the restoration: First open all windows in creation order. After all + // windows have opened, we restore states to windows in reversed z-order. + this._openWindows(root).then(windows => { + // We want to add current window to opened window, so that this window will be + // restored in reversed z-order. (We add the window to first position, in case + // no z-indices are found, that window will be restored first.) + windows.unshift(aWindow); + + this._restoreWindowsInReversedZOrder(windows); + }); + + lazy.DevToolsShim.restoreDevToolsSession(aState); + }, + + /** + * Manage history restoration for a window + * @param aWindow + * Window to restore the tabs into + * @param aTabs + * Array of tab references + * @param aTabData + * Array of tab data + * @param aSelectTab + * Index of the tab to select. This is a 1-based index where "1" + * indicates the first tab should be selected, and "0" indicates that + * the currently selected tab will not be changed. + */ + restoreTabs(aWindow, aTabs, aTabData, aSelectTab) { + var tabbrowser = aWindow.gBrowser; + + let numTabsToRestore = aTabs.length; + let numTabsInWindow = tabbrowser.tabs.length; + let tabsDataArray = this._windows[aWindow.__SSi].tabs; + + // Update the window state in case we shut down without being notified. + // Individual tab states will be taken care of by restoreTab() below. + if (numTabsInWindow == numTabsToRestore) { + // Remove all previous tab data. + tabsDataArray.length = 0; + } else { + // Remove all previous tab data except tabs that should not be overriden. + tabsDataArray.splice(numTabsInWindow - numTabsToRestore); + } + + // Remove items from aTabData if there is no corresponding tab: + if (numTabsInWindow < tabsDataArray.length) { + tabsDataArray.length = numTabsInWindow; + } + + // Ensure the tab data array has items for each of the tabs + this._ensureNoNullsInTabDataList( + tabbrowser.tabs, + tabsDataArray, + numTabsInWindow - 1 + ); + + if (aSelectTab > 0 && aSelectTab <= aTabs.length) { + // Update the window state in case we shut down without being notified. + this._windows[aWindow.__SSi].selected = aSelectTab; + } + + // If we restore the selected tab, make sure it goes first. + let selectedIndex = aTabs.indexOf(tabbrowser.selectedTab); + if (selectedIndex > -1) { + this.restoreTab(tabbrowser.selectedTab, aTabData[selectedIndex]); + } + + // Restore all tabs. + for (let t = 0; t < aTabs.length; t++) { + if (t != selectedIndex) { + this.restoreTab(aTabs[t], aTabData[t]); + } + } + }, + + // In case we didn't collect/receive data for any tabs yet we'll have to + // fill the array with at least empty tabData objects until |_tPos| or + // we'll end up with |null| entries. + _ensureNoNullsInTabDataList(tabElements, tabDataList, changedTabPos) { + let initialDataListLength = tabDataList.length; + if (changedTabPos < initialDataListLength) { + return; + } + // Add items to the end. + while (tabDataList.length < changedTabPos) { + let existingTabEl = tabElements[tabDataList.length]; + tabDataList.push({ + entries: [], + lastAccessed: existingTabEl.lastAccessed, + }); + } + // Ensure the pre-existing items are non-null. + for (let i = 0; i < initialDataListLength; i++) { + if (!tabDataList[i]) { + let existingTabEl = tabElements[i]; + tabDataList[i] = { + entries: [], + lastAccessed: existingTabEl.lastAccessed, + }; + } + } + }, + + // Restores the given tab state for a given tab. + restoreTab(tab, tabData, options = {}) { + let browser = tab.linkedBrowser; + + if (TAB_STATE_FOR_BROWSER.has(browser)) { + console.error("Must reset tab before calling restoreTab."); + return; + } + + let loadArguments = options.loadArguments; + let window = tab.ownerGlobal; + let tabbrowser = window.gBrowser; + let forceOnDemand = options.forceOnDemand; + let isRemotenessUpdate = options.isRemotenessUpdate; + + let willRestoreImmediately = + options.restoreImmediately || tabbrowser.selectedBrowser == browser; + + let isBrowserInserted = browser.isConnected; + + // Increase the busy state counter before modifying the tab. + this._setWindowStateBusy(window); + + // It's important to set the window state to dirty so that + // we collect their data for the first time when saving state. + DirtyWindows.add(window); + + if (!tab.hasOwnProperty("_tPos")) { + throw new Error( + "Shouldn't be trying to restore a tab that has no position" + ); + } + // Update the tab state in case we shut down without being notified. + this._windows[window.__SSi].tabs[tab._tPos] = tabData; + + // Prepare the tab so that it can be properly restored. We'll also attach + // a copy of the tab's data in case we close it before it's been restored. + // Anything that dispatches an event to external consumers must happen at + // the end of this method, to make sure that the tab/browser object is in a + // reliable and consistent state. + + if (tabData.lastAccessed) { + tab.updateLastAccessed(tabData.lastAccessed); + } + + if ("attributes" in tabData) { + // Ensure that we persist tab attributes restored from previous sessions. + Object.keys(tabData.attributes).forEach(a => + lazy.TabAttributes.persist(a) + ); + } + + if (!tabData.entries) { + tabData.entries = []; + } + if (tabData.extData) { + TAB_CUSTOM_VALUES.set(tab, Cu.cloneInto(tabData.extData, {})); + } else { + TAB_CUSTOM_VALUES.delete(tab); + } + + // Tab is now open. + delete tabData.closedAt; + + // Ensure the index is in bounds. + let activeIndex = (tabData.index || tabData.entries.length) - 1; + activeIndex = Math.min(activeIndex, tabData.entries.length - 1); + activeIndex = Math.max(activeIndex, 0); + + // Save the index in case we updated it above. + tabData.index = activeIndex + 1; + + tab.setAttribute("pending", "true"); + + // If we're restoring this tab, it certainly shouldn't be in + // the ignored set anymore. + this._crashedBrowsers.delete(browser.permanentKey); + + // If we're in the midst of performing a process flip, then we must + // have initiated a navigation. This means that these userTyped* + // values are now out of date. + if ( + options.restoreContentReason == + RESTORE_TAB_CONTENT_REASON.NAVIGATE_AND_RESTORE + ) { + delete tabData.userTypedValue; + delete tabData.userTypedClear; + } + + // Update the persistent tab state cache with |tabData| information. + lazy.TabStateCache.update(browser.permanentKey, { + // NOTE: Copy the entries array shallowly, so as to not screw with the + // original tabData's history when getting history updates. + history: { entries: [...tabData.entries], index: tabData.index }, + scroll: tabData.scroll || null, + storage: tabData.storage || null, + formdata: tabData.formdata || null, + disallow: tabData.disallow || null, + userContextId: tabData.userContextId || 0, + + // This information is only needed until the tab has finished restoring. + // When that's done it will be removed from the cache and we always + // collect it in TabState._collectBaseTabData(). + image: tabData.image || "", + iconLoadingPrincipal: tabData.iconLoadingPrincipal || null, + searchMode: tabData.searchMode || null, + userTypedValue: tabData.userTypedValue || "", + userTypedClear: tabData.userTypedClear || 0, + }); + + // Restore tab attributes. + if ("attributes" in tabData) { + lazy.TabAttributes.set(tab, tabData.attributes); + } + + if (isBrowserInserted) { + // Start a new epoch to discard all frame script messages relating to a + // previous epoch. All async messages that are still on their way to chrome + // will be ignored and don't override any tab data set when restoring. + let epoch = this.startNextEpoch(browser.permanentKey); + + // Ensure that the tab will get properly restored in the event the tab + // crashes while restoring. But don't set this on lazy browsers as + // restoreTab will get called again when the browser is instantiated. + TAB_STATE_FOR_BROWSER.set(browser, TAB_STATE_NEEDS_RESTORE); + + this._sendRestoreHistory(browser, { + tabData, + epoch, + loadArguments, + isRemotenessUpdate, + }); + + // This could cause us to ignore MAX_CONCURRENT_TAB_RESTORES a bit, but + // it ensures each window will have its selected tab loaded. + if (willRestoreImmediately) { + this.restoreTabContent(tab, options); + } else if (!forceOnDemand) { + TabRestoreQueue.add(tab); + // Check if a tab is in queue and will be restored + // after the currently loading tabs. If so, prepare + // a connection to host to speed up page loading. + if (TabRestoreQueue.willRestoreSoon(tab)) { + if (activeIndex in tabData.entries) { + let url = tabData.entries[activeIndex].url; + let prepared = this.prepareConnectionToHost(tab, url); + if (gDebuggingEnabled) { + tab.__test_connection_prepared = prepared; + tab.__test_connection_url = url; + } + } + } + this.restoreNextTab(); + } + } else { + // TAB_LAZY_STATES holds data for lazy-browser tabs to proxy for + // data unobtainable from the unbound browser. This only applies to lazy + // browsers and will be removed once the browser is inserted in the document. + // This must preceed `updateTabLabelAndIcon` call for required data to be present. + let url = "about:blank"; + let title = ""; + + if (activeIndex in tabData.entries) { + url = tabData.entries[activeIndex].url; + title = tabData.entries[activeIndex].title || url; + } + TAB_LAZY_STATES.set(tab, { + url, + title, + userTypedValue: tabData.userTypedValue || "", + userTypedClear: tabData.userTypedClear || 0, + }); + } + + // Most of tabData has been restored, now continue with restoring + // attributes that may trigger external events. + + if (tabData.pinned) { + tabbrowser.pinTab(tab); + } else { + tabbrowser.unpinTab(tab); + } + + if (tabData.hidden) { + tabbrowser.hideTab(tab); + } else { + tabbrowser.showTab(tab); + } + + if (!!tabData.muted != browser.audioMuted) { + tab.toggleMuteAudio(tabData.muteReason); + } + + if (tab.hasAttribute("customizemode")) { + window.gCustomizeMode.setTab(tab); + } + + // Update tab label and icon to show something + // while we wait for the messages to be processed. + this.updateTabLabelAndIcon(tab, tabData); + + // Decrease the busy state counter after we're done. + this._setWindowStateReady(window); + }, + + /** + * Kicks off restoring the given tab. + * + * @param aTab + * the tab to restore + * @param aOptions + * optional arguments used when performing process switch during load + */ + restoreTabContent(aTab, aOptions = {}) { + let loadArguments = aOptions.loadArguments; + if (aTab.hasAttribute("customizemode") && !loadArguments) { + return; + } + + let browser = aTab.linkedBrowser; + let window = aTab.ownerGlobal; + let tabbrowser = window.gBrowser; + let tabData = lazy.TabState.clone(aTab, TAB_CUSTOM_VALUES.get(aTab)); + let activeIndex = tabData.index - 1; + let activePageData = tabData.entries[activeIndex] || null; + let uri = activePageData ? activePageData.url || null : null; + + this.markTabAsRestoring(aTab); + + let isRemotenessUpdate = aOptions.isRemotenessUpdate; + let explicitlyUpdateRemoteness = !Services.appinfo.sessionHistoryInParent; + // If we aren't already updating the browser's remoteness, check if it's + // necessary. + if (explicitlyUpdateRemoteness && !isRemotenessUpdate) { + isRemotenessUpdate = tabbrowser.updateBrowserRemotenessByURL( + browser, + uri + ); + + if (isRemotenessUpdate) { + // We updated the remoteness, so we need to send the history down again. + // + // Start a new epoch to discard all frame script messages relating to a + // previous epoch. All async messages that are still on their way to chrome + // will be ignored and don't override any tab data set when restoring. + let epoch = this.startNextEpoch(browser.permanentKey); + + this._sendRestoreHistory(browser, { + tabData, + epoch, + loadArguments, + isRemotenessUpdate, + }); + } + } + + this._sendRestoreTabContent(browser, { + loadArguments, + isRemotenessUpdate, + reason: + aOptions.restoreContentReason || RESTORE_TAB_CONTENT_REASON.SET_STATE, + }); + + // Focus the tab's content area, unless the restore is for a new tab URL or + // was triggered by a DocumentChannel process switch. + if ( + aTab.selected && + !window.isBlankPageURL(uri) && + !aOptions.isRemotenessUpdate + ) { + browser.focus(); + } + }, + + /** + * Marks a given pending tab as restoring. + * + * @param aTab + * the pending tab to mark as restoring + */ + markTabAsRestoring(aTab) { + let browser = aTab.linkedBrowser; + if (TAB_STATE_FOR_BROWSER.get(browser) != TAB_STATE_NEEDS_RESTORE) { + throw new Error("Given tab is not pending."); + } + + // Make sure that this tab is removed from the priority queue. + TabRestoreQueue.remove(aTab); + + // Increase our internal count. + this._tabsRestoringCount++; + + // Set this tab's state to restoring + TAB_STATE_FOR_BROWSER.set(browser, TAB_STATE_RESTORING); + aTab.removeAttribute("pending"); + }, + + /** + * This _attempts_ to restore the next available tab. If the restore fails, + * then we will attempt the next one. + * There are conditions where this won't do anything: + * if we're in the process of quitting + * if there are no tabs to restore + * if we have already reached the limit for number of tabs to restore + */ + restoreNextTab: function ssi_restoreNextTab() { + // If we call in here while quitting, we don't actually want to do anything + if (lazy.RunState.isQuitting) { + return; + } + + // Don't exceed the maximum number of concurrent tab restores. + if (this._tabsRestoringCount >= MAX_CONCURRENT_TAB_RESTORES) { + return; + } + + let tab = TabRestoreQueue.shift(); + if (tab) { + this.restoreTabContent(tab); + } + }, + + /** + * Restore visibility and dimension features to a window + * @param aWindow + * Window reference + * @param aWinData + * Object containing session data for the window + */ + restoreWindowFeatures: function ssi_restoreWindowFeatures(aWindow, aWinData) { + var hidden = aWinData.hidden ? aWinData.hidden.split(",") : []; + WINDOW_HIDEABLE_FEATURES.forEach(function (aItem) { + aWindow[aItem].visible = !hidden.includes(aItem); + }); + + if (aWinData.isPopup) { + this._windows[aWindow.__SSi].isPopup = true; + if (aWindow.gURLBar) { + aWindow.gURLBar.readOnly = true; + } + } else { + delete this._windows[aWindow.__SSi].isPopup; + if (aWindow.gURLBar) { + aWindow.gURLBar.readOnly = false; + } + } + + aWindow.setTimeout(() => { + this.restoreDimensions( + aWindow, + +(aWinData.width || 0), + +(aWinData.height || 0), + "screenX" in aWinData ? +aWinData.screenX : NaN, + "screenY" in aWinData ? +aWinData.screenY : NaN, + aWinData.sizemode || "", + aWinData.sizemodeBeforeMinimized || "", + aWinData.sidebar || "" + ); + }, 0); + }, + + /** + * Restore a window's dimensions + * @param aWidth + * Window width in desktop pixels + * @param aHeight + * Window height in desktop pixels + * @param aLeft + * Window left in desktop pixels + * @param aTop + * Window top in desktop pixels + * @param aSizeMode + * Window size mode (eg: maximized) + * @param aSizeModeBeforeMinimized + * Window size mode before window got minimized (eg: maximized) + * @param aSidebar + * Sidebar command + */ + restoreDimensions: function ssi_restoreDimensions( + aWindow, + aWidth, + aHeight, + aLeft, + aTop, + aSizeMode, + aSizeModeBeforeMinimized, + aSidebar + ) { + var win = aWindow; + var _this = this; + function win_(aName) { + return _this._getWindowDimension(win, aName); + } + + const dwu = win.windowUtils; + // find available space on the screen where this window is being placed + let screen = lazy.gScreenManager.screenForRect( + aLeft, + aTop, + aWidth, + aHeight + ); + if (screen) { + let screenLeft = {}, + screenTop = {}, + screenWidth = {}, + screenHeight = {}; + screen.GetAvailRectDisplayPix( + screenLeft, + screenTop, + screenWidth, + screenHeight + ); + + // We store aLeft / aTop (screenX/Y) in desktop pixels, see + // _getWindowDimension. + screenLeft = screenLeft.value; + screenTop = screenTop.value; + screenWidth = screenWidth.value; + screenHeight = screenHeight.value; + + let screenBottom = screenTop + screenHeight; + let screenRight = screenLeft + screenWidth; + + // NOTE: contentsScaleFactor is the desktopToDeviceScale of the screen. + // Naming could be more consistent here. + let cssToDesktopScale = + screen.defaultCSSScaleFactor / screen.contentsScaleFactor; + + let winSlopX = win.screenEdgeSlopX * cssToDesktopScale; + let winSlopY = win.screenEdgeSlopY * cssToDesktopScale; + + let minSlop = MIN_SCREEN_EDGE_SLOP * cssToDesktopScale; + let slopX = Math.max(minSlop, winSlopX); + let slopY = Math.max(minSlop, winSlopY); + + // Pull the window within the screen's bounds (allowing a little slop + // for windows that may be deliberately placed with their border off-screen + // as when Win10 "snaps" a window to the left/right edge -- bug 1276516). + // First, ensure the left edge is large enough... + if (aLeft < screenLeft - slopX) { + aLeft = screenLeft - winSlopX; + } + // Then check the resulting right edge, and reduce it if necessary. + let right = aLeft + aWidth * cssToDesktopScale; + if (right > screenRight + slopX) { + right = screenRight + winSlopX; + // See if we can move the left edge leftwards to maintain width. + if (aLeft > screenLeft) { + aLeft = Math.max( + right - aWidth * cssToDesktopScale, + screenLeft - winSlopX + ); + } + } + // Finally, update aWidth to account for the adjusted left and right + // edges, and convert it back to CSS pixels on the target screen. + aWidth = (right - aLeft) / cssToDesktopScale; + + // And do the same in the vertical dimension. + if (aTop < screenTop - slopY) { + aTop = screenTop - winSlopY; + } + let bottom = aTop + aHeight * cssToDesktopScale; + if (bottom > screenBottom + slopY) { + bottom = screenBottom + winSlopY; + if (aTop > screenTop) { + aTop = Math.max( + bottom - aHeight * cssToDesktopScale, + screenTop - winSlopY + ); + } + } + aHeight = (bottom - aTop) / cssToDesktopScale; + } + + // Suppress animations. + dwu.suppressAnimation(true); + + // We want to make sure users will get their animations back in case an exception is thrown. + try { + // only modify those aspects which aren't correct yet + if ( + !isNaN(aLeft) && + !isNaN(aTop) && + (aLeft != win_("screenX") || aTop != win_("screenY")) + ) { + // moveTo uses CSS pixels relative to aWindow, while aLeft and aRight + // are on desktop pixels, undo the conversion we do in + // _getWindowDimension. + let desktopToCssScale = + aWindow.desktopToDeviceScale / aWindow.devicePixelRatio; + aWindow.moveTo(aLeft * desktopToCssScale, aTop * desktopToCssScale); + } + if ( + aWidth && + aHeight && + (aWidth != win_("width") || aHeight != win_("height")) && + !gResistFingerprintingEnabled + ) { + // Don't resize the window if it's currently maximized and we would + // maximize it again shortly after. + if (aSizeMode != "maximized" || win_("sizemode") != "maximized") { + aWindow.resizeTo(aWidth, aHeight); + } + } + this._windows[aWindow.__SSi].sizemodeBeforeMinimized = + aSizeModeBeforeMinimized; + if ( + aSizeMode && + win_("sizemode") != aSizeMode && + !gResistFingerprintingEnabled + ) { + switch (aSizeMode) { + case "maximized": + aWindow.maximize(); + break; + case "minimized": + if (aSizeModeBeforeMinimized == "maximized") { + aWindow.maximize(); + } + aWindow.minimize(); + break; + case "normal": + aWindow.restore(); + break; + } + } + let sidebarBox = aWindow.document.getElementById("sidebar-box"); + if ( + aSidebar && + (sidebarBox.getAttribute("sidebarcommand") != aSidebar || + !sidebarBox.getAttribute("checked")) + ) { + aWindow.SidebarUI.showInitially(aSidebar); + } + // since resizing/moving a window brings it to the foreground, + // we might want to re-focus the last focused window + if (this.windowToFocus) { + this.windowToFocus.focus(); + } + } finally { + // Enable animations. + dwu.suppressAnimation(false); + } + }, + + /* ........ Disk Access .............. */ + + /** + * Save the current session state to disk, after a delay. + * + * @param aWindow (optional) + * Will mark the given window as dirty so that we will recollect its + * data before we start writing. + */ + saveStateDelayed(aWindow = null) { + if (aWindow) { + DirtyWindows.add(aWindow); + } + + lazy.SessionSaver.runDelayed(); + }, + + /* ........ Auxiliary Functions .............. */ + + /** + * Remove a closed window from the list of closed windows and indicate that + * the change should be notified. + * + * @param index + * The index of the window in this._closedWindows. + * + * @returns Array of closed windows. + */ + _removeClosedWindow(index) { + let windows = this._closedWindows.splice(index, 1); + this._closedObjectsChanged = true; + return windows; + }, + + /** + * Notifies observers that the list of closed tabs and/or windows has changed. + * Waits a tick to allow SessionStorage a chance to register the change. + */ + _notifyOfClosedObjectsChange() { + if (!this._closedObjectsChanged) { + return; + } + this._closedObjectsChanged = false; + lazy.setTimeout(() => { + Services.obs.notifyObservers(null, NOTIFY_CLOSED_OBJECTS_CHANGED); + }, 0); + }, + + /** + * Update the session start time and send a telemetry measurement + * for the number of days elapsed since the session was started. + * + * @param state + * The session state. + */ + _updateSessionStartTime: function ssi_updateSessionStartTime(state) { + // Attempt to load the session start time from the session state + if (state.session && state.session.startTime) { + this._sessionStartTime = state.session.startTime; + } + }, + + /** + * Iterator that yields all currently opened browser windows. + * (Might miss the most recent one.) + * This list is in focus order, but may include minimized windows + * before non-minimized windows. + */ + _browserWindows: { + *[Symbol.iterator]() { + for (let window of lazy.BrowserWindowTracker.orderedWindows) { + if (window.__SSi && !window.closed) { + yield window; + } + } + }, + }, + + /** + * Iterator that yields all currently opened browser windows, + * with minimized windows last. + * (Might miss the most recent window.) + */ + _orderedBrowserWindows: { + *[Symbol.iterator]() { + let windows = lazy.BrowserWindowTracker.orderedWindows; + windows.sort((a, b) => { + if ( + a.windowState == a.STATE_MINIMIZED && + b.windowState != b.STATE_MINIMIZED + ) { + return 1; + } + if ( + a.windowState != a.STATE_MINIMIZED && + b.windowState == b.STATE_MINIMIZED + ) { + return -1; + } + return 0; + }); + for (let window of windows) { + if (window.__SSi && !window.closed) { + yield window; + } + } + }, + }, + + /** + * Returns most recent window + * @returns Window reference + */ + _getTopWindow: function ssi_getTopWindow() { + return lazy.BrowserWindowTracker.getTopWindow({ allowPopups: true }); + }, + + /** + * Calls onClose for windows that are determined to be closed but aren't + * destroyed yet, which would otherwise cause getBrowserState and + * setBrowserState to treat them as open windows. + */ + _handleClosedWindows: function ssi_handleClosedWindows() { + let promises = []; + for (let window of Services.wm.getEnumerator("navigator:browser")) { + if (window.closed) { + promises.push(this.onClose(window)); + } + } + return Promise.all(promises); + }, + + /** + * Store a restore state of a window to this._statesToRestore. The window + * will be given an id that can be used to get the restore state from + * this._statesToRestore. + * + * @param window + * a reference to a window that has a state to restore + * @param state + * an object containing session data + */ + _updateWindowRestoreState(window, state) { + // Store z-index, so that windows can be restored in reversed z-order. + if ("zIndex" in state.windows[0]) { + WINDOW_RESTORE_ZINDICES.set(window, state.windows[0].zIndex); + } + do { + var ID = "window" + Math.random(); + } while (ID in this._statesToRestore); + WINDOW_RESTORE_IDS.set(window, ID); + this._statesToRestore[ID] = state; + }, + + /** + * open a new browser window for a given session state + * called when restoring a multi-window session + * @param aState + * Object containing session data + */ + _openWindowWithState: function ssi_openWindowWithState(aState) { + var argString = Cc["@mozilla.org/supports-string;1"].createInstance( + Ci.nsISupportsString + ); + argString.data = ""; + + // Build feature string + let features; + let winState = aState.windows[0]; + if (winState.chromeFlags) { + features = ["chrome", "suppressanimation"]; + let chromeFlags = winState.chromeFlags; + const allFlags = Ci.nsIWebBrowserChrome.CHROME_ALL; + const hasAll = (chromeFlags & allFlags) == allFlags; + if (hasAll) { + features.push("all"); + } + for (let [flag, onValue, offValue] of CHROME_FLAGS_MAP) { + if (hasAll && allFlags & flag) { + continue; + } + let value = chromeFlags & flag ? onValue : offValue; + if (value) { + features.push(value); + } + } + } else { + // |chromeFlags| is not found. Fallbacks to the old method. + features = ["chrome", "dialog=no", "suppressanimation"]; + let hidden = winState.hidden?.split(",") || []; + if (!hidden.length) { + features.push("all"); + } else { + features.push("resizable"); + WINDOW_HIDEABLE_FEATURES.forEach(aFeature => { + if (!hidden.includes(aFeature)) { + features.push(WINDOW_OPEN_FEATURES_MAP[aFeature] || aFeature); + } + }); + } + } + WINDOW_ATTRIBUTES.forEach(aFeature => { + // Use !isNaN as an easy way to ignore sizemode and check for numbers + if (aFeature in winState && !isNaN(winState[aFeature])) { + features.push(aFeature + "=" + winState[aFeature]); + } + }); + + if (winState.isPrivate) { + features.push("private"); + } + + var window = Services.ww.openWindow( + null, + AppConstants.BROWSER_CHROME_URL, + "_blank", + features.join(","), + argString + ); + + this._updateWindowRestoreState(window, aState); + WINDOW_SHOWING_PROMISES.set(window, lazy.PromiseUtils.defer()); + + return window; + }, + + /** + * whether the user wants to load any other page at startup + * (except the homepage) - needed for determining whether to overwrite the current tabs + * C.f.: nsBrowserContentHandler's defaultArgs implementation. + * @returns bool + */ + _isCmdLineEmpty: function ssi_isCmdLineEmpty(aWindow, aState) { + var pinnedOnly = + aState.windows && + aState.windows.every(win => win.tabs.every(tab => tab.pinned)); + + let hasFirstArgument = aWindow.arguments && aWindow.arguments[0]; + if (!pinnedOnly) { + let defaultArgs = Cc["@mozilla.org/browser/clh;1"].getService( + Ci.nsIBrowserHandler + ).defaultArgs; + if ( + aWindow.arguments && + aWindow.arguments[0] && + aWindow.arguments[0] == defaultArgs + ) { + hasFirstArgument = false; + } + } + + return !hasFirstArgument; + }, + + /** + * on popup windows, the AppWindow's attributes seem not to be set correctly + * we use thus JSDOMWindow attributes for sizemode and normal window attributes + * (and hope for reasonable values when maximized/minimized - since then + * outerWidth/outerHeight aren't the dimensions of the restored window) + * @param aWindow + * Window reference + * @param aAttribute + * String sizemode | width | height | other window attribute + * @returns string + */ + _getWindowDimension: function ssi_getWindowDimension(aWindow, aAttribute) { + if (aAttribute == "sizemode") { + switch (aWindow.windowState) { + case aWindow.STATE_FULLSCREEN: + case aWindow.STATE_MAXIMIZED: + return "maximized"; + case aWindow.STATE_MINIMIZED: + return "minimized"; + default: + return "normal"; + } + } + + // We want to persist the size / position in normal state, so that + // we can restore to them even if the window is currently maximized + // or minimized. However, attributes on window object only reflect + // the current state of the window, so when it isn't in the normal + // sizemode, their values aren't what we want the window to restore + // to. In that case, try to read from the attributes of the root + // element first instead. + if (aWindow.windowState != aWindow.STATE_NORMAL) { + let docElem = aWindow.document.documentElement; + let attr = parseInt(docElem.getAttribute(aAttribute), 10); + if (attr) { + if (aAttribute != "width" && aAttribute != "height") { + return attr; + } + // Width and height attribute report the inner size, but we want + // to store the outer size, so add the difference. + let appWin = aWindow.docShell.treeOwner + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIAppWindow); + let diff = + aAttribute == "width" + ? appWin.outerToInnerWidthDifferenceInCSSPixels + : appWin.outerToInnerHeightDifferenceInCSSPixels; + return attr + diff; + } + } + + switch (aAttribute) { + case "width": + return aWindow.outerWidth; + case "height": + return aWindow.outerHeight; + case "screenX": + case "screenY": + // We use desktop pixels rather than CSS pixels to store window + // positions, see bug 1247335. This allows proper multi-monitor + // positioning in mixed-DPI situations. + // screenX/Y are in CSS pixels for the current window, so, convert them + // to desktop pixels. + return ( + (aWindow[aAttribute] * aWindow.devicePixelRatio) / + aWindow.desktopToDeviceScale + ); + default: + return aAttribute in aWindow ? aWindow[aAttribute] : ""; + } + }, + + /** + * @param aState is a session state + * @param aRecentCrashes is the number of consecutive crashes + * @returns whether a restore page will be needed for the session state + */ + _needsRestorePage: function ssi_needsRestorePage(aState, aRecentCrashes) { + const SIX_HOURS_IN_MS = 6 * 60 * 60 * 1000; + + // don't display the page when there's nothing to restore + let winData = aState.windows || null; + if (!winData || !winData.length) { + return false; + } + + // don't wrap a single about:sessionrestore page + if ( + this._hasSingleTabWithURL(winData, "about:sessionrestore") || + this._hasSingleTabWithURL(winData, "about:welcomeback") + ) { + return false; + } + + // don't automatically restore in Safe Mode + if (Services.appinfo.inSafeMode) { + return true; + } + + let max_resumed_crashes = this._prefBranch.getIntPref( + "sessionstore.max_resumed_crashes" + ); + let sessionAge = + aState.session && + aState.session.lastUpdate && + Date.now() - aState.session.lastUpdate; + + let decision = + max_resumed_crashes != -1 && + (aRecentCrashes > max_resumed_crashes || + (sessionAge && sessionAge >= SIX_HOURS_IN_MS)); + if (decision) { + let key; + if (aRecentCrashes > max_resumed_crashes) { + if (sessionAge && sessionAge >= SIX_HOURS_IN_MS) { + key = "shown_many_crashes_old_session"; + } else { + key = "shown_many_crashes"; + } + } else { + key = "shown_old_session"; + } + Services.telemetry.keyedScalarAdd( + "browser.engagement.sessionrestore_interstitial", + key, + 1 + ); + } + return decision; + }, + + /** + * @param aWinData is the set of windows in session state + * @param aURL is the single URL we're looking for + * @returns whether the window data contains only the single URL passed + */ + _hasSingleTabWithURL(aWinData, aURL) { + if ( + aWinData && + aWinData.length == 1 && + aWinData[0].tabs && + aWinData[0].tabs.length == 1 && + aWinData[0].tabs[0].entries && + aWinData[0].tabs[0].entries.length == 1 + ) { + return aURL == aWinData[0].tabs[0].entries[0].url; + } + return false; + }, + + /** + * Determine if the tab state we're passed is something we should save. This + * is used when closing a tab or closing a window with a single tab + * + * @param aTabState + * The current tab state + * @returns boolean + */ + _shouldSaveTabState: function ssi_shouldSaveTabState(aTabState) { + // If the tab has only a transient about: history entry, no other + // session history, and no userTypedValue, then we don't actually want to + // store this tab's data. + return ( + aTabState.entries.length && + !( + aTabState.entries.length == 1 && + (aTabState.entries[0].url == "about:blank" || + aTabState.entries[0].url == "about:newtab" || + aTabState.entries[0].url == "about:privatebrowsing") && + !aTabState.userTypedValue + ) + ); + }, + + /** + * Determine if the tab state we're passed is something we should keep to be + * reopened at session restore. This is used when we are saving the current + * session state to disk. This method is very similar to _shouldSaveTabState, + * however, "about:blank" and "about:newtab" tabs will still be saved to disk. + * + * @param aTabState + * The current tab state + * @returns boolean + */ + _shouldSaveTab: function ssi_shouldSaveTab(aTabState) { + // If the tab has one of the following transient about: history entry, no + // userTypedValue, and no customizemode attribute, then we don't actually + // want to write this tab's data to disk. + return ( + aTabState.userTypedValue || + (aTabState.attributes && aTabState.attributes.customizemode == "true") || + (aTabState.entries.length && + aTabState.entries[0].url != "about:privatebrowsing") + ); + }, + + /** + * This is going to take a state as provided at startup (via + * SessionStartup.state) and split it into 2 parts. The first part + * (defaultState) will be a state that should still be restored at startup, + * while the second part (state) is a state that should be saved for later. + * defaultState will be comprised of windows with only pinned tabs, extracted + * from state. It will also contain window position information. + * + * defaultState will be restored at startup. state will be passed into + * LastSession and will be kept in case the user explicitly wants + * to restore the previous session (publicly exposed as restoreLastSession). + * + * @param state + * The state, presumably from SessionStartup.state + * @returns [defaultState, state] + */ + _prepDataForDeferredRestore: function ssi_prepDataForDeferredRestore(state) { + // Make sure that we don't modify the global state as provided by + // SessionStartup.state. + state = Cu.cloneInto(state, {}); + + let defaultState = { windows: [], selectedWindow: 1 }; + + state.selectedWindow = state.selectedWindow || 1; + + // Look at each window, remove pinned tabs, adjust selectedindex, + // remove window if necessary. + for (let wIndex = 0; wIndex < state.windows.length; ) { + let window = state.windows[wIndex]; + window.selected = window.selected || 1; + // We're going to put the state of the window into this object + let pinnedWindowState = { tabs: [] }; + for (let tIndex = 0; tIndex < window.tabs.length; ) { + if (window.tabs[tIndex].pinned) { + // Adjust window.selected + if (tIndex + 1 < window.selected) { + window.selected -= 1; + } else if (tIndex + 1 == window.selected) { + pinnedWindowState.selected = pinnedWindowState.tabs.length + 1; + } + // + 1 because the tab isn't actually in the array yet + + // Now add the pinned tab to our window + pinnedWindowState.tabs = pinnedWindowState.tabs.concat( + window.tabs.splice(tIndex, 1) + ); + // We don't want to increment tIndex here. + continue; + } + tIndex++; + } + + // At this point the window in the state object has been modified (or not) + // We want to build the rest of this new window object if we have pinnedTabs. + if (pinnedWindowState.tabs.length) { + // First get the other attributes off the window + WINDOW_ATTRIBUTES.forEach(function (attr) { + if (attr in window) { + pinnedWindowState[attr] = window[attr]; + delete window[attr]; + } + }); + // We're just copying position data into the pinned window. + // Not copying over: + // - _closedTabs + // - extData + // - isPopup + // - hidden + + // Assign a unique ID to correlate the window to be opened with the + // remaining data + window.__lastSessionWindowID = pinnedWindowState.__lastSessionWindowID = + "" + Date.now() + Math.random(); + + // Actually add this window to our defaultState + defaultState.windows.push(pinnedWindowState); + // Remove the window from the state if it doesn't have any tabs + if (!window.tabs.length) { + if (wIndex + 1 <= state.selectedWindow) { + state.selectedWindow -= 1; + } else if (wIndex + 1 == state.selectedWindow) { + defaultState.selectedIndex = defaultState.windows.length + 1; + } + + state.windows.splice(wIndex, 1); + // We don't want to increment wIndex here. + continue; + } + } + wIndex++; + } + + return [defaultState, state]; + }, + + _sendRestoreCompletedNotifications: + function ssi_sendRestoreCompletedNotifications() { + // not all windows restored, yet + if (this._restoreCount > 1) { + this._restoreCount--; + return; + } + + // observers were already notified + if (this._restoreCount == -1) { + return; + } + + // This was the last window restored at startup, notify observers. + if (!this._browserSetState) { + Services.obs.notifyObservers(null, NOTIFY_WINDOWS_RESTORED); + this._deferredAllWindowsRestored.resolve(); + } else { + // _browserSetState is used only by tests, and it uses an alternate + // notification in order not to retrigger startup observers that + // are listening for NOTIFY_WINDOWS_RESTORED. + Services.obs.notifyObservers(null, NOTIFY_BROWSER_STATE_RESTORED); + } + + this._browserSetState = false; + this._restoreCount = -1; + }, + + /** + * Set the given window's busy state + * @param aWindow the window + * @param aValue the window's busy state + */ + _setWindowStateBusyValue: function ssi_changeWindowStateBusyValue( + aWindow, + aValue + ) { + this._windows[aWindow.__SSi].busy = aValue; + + // Keep the to-be-restored state in sync because that is returned by + // getWindowState() as long as the window isn't loaded, yet. + if (!this._isWindowLoaded(aWindow)) { + let stateToRestore = + this._statesToRestore[WINDOW_RESTORE_IDS.get(aWindow)].windows[0]; + stateToRestore.busy = aValue; + } + }, + + /** + * Set the given window's state to 'not busy'. + * @param aWindow the window + */ + _setWindowStateReady: function ssi_setWindowStateReady(aWindow) { + let newCount = (this._windowBusyStates.get(aWindow) || 0) - 1; + if (newCount < 0) { + throw new Error("Invalid window busy state (less than zero)."); + } + this._windowBusyStates.set(aWindow, newCount); + + if (newCount == 0) { + this._setWindowStateBusyValue(aWindow, false); + this._sendWindowStateReadyEvent(aWindow); + } + }, + + /** + * Set the given window's state to 'busy'. + * @param aWindow the window + */ + _setWindowStateBusy: function ssi_setWindowStateBusy(aWindow) { + let newCount = (this._windowBusyStates.get(aWindow) || 0) + 1; + this._windowBusyStates.set(aWindow, newCount); + + if (newCount == 1) { + this._setWindowStateBusyValue(aWindow, true); + this._sendWindowStateBusyEvent(aWindow); + } + }, + + /** + * Dispatch an SSWindowStateReady event for the given window. + * @param aWindow the window + */ + _sendWindowStateReadyEvent: function ssi_sendWindowStateReadyEvent(aWindow) { + let event = aWindow.document.createEvent("Events"); + event.initEvent("SSWindowStateReady", true, false); + aWindow.dispatchEvent(event); + }, + + /** + * Dispatch an SSWindowStateBusy event for the given window. + * @param aWindow the window + */ + _sendWindowStateBusyEvent: function ssi_sendWindowStateBusyEvent(aWindow) { + let event = aWindow.document.createEvent("Events"); + event.initEvent("SSWindowStateBusy", true, false); + aWindow.dispatchEvent(event); + }, + + /** + * Dispatch the SSWindowRestoring event for the given window. + * @param aWindow + * The window which is going to be restored + */ + _sendWindowRestoringNotification(aWindow) { + let event = aWindow.document.createEvent("Events"); + event.initEvent("SSWindowRestoring", true, false); + aWindow.dispatchEvent(event); + }, + + /** + * Dispatch the SSWindowRestored event for the given window. + * @param aWindow + * The window which has been restored + */ + _sendWindowRestoredNotification(aWindow) { + let event = aWindow.document.createEvent("Events"); + event.initEvent("SSWindowRestored", true, false); + aWindow.dispatchEvent(event); + }, + + /** + * Dispatch the SSTabRestored event for the given tab. + * @param aTab + * The tab which has been restored + * @param aIsRemotenessUpdate + * True if this tab was restored due to flip from running from + * out-of-main-process to in-main-process or vice-versa. + */ + _sendTabRestoredNotification(aTab, aIsRemotenessUpdate) { + let event = aTab.ownerDocument.createEvent("CustomEvent"); + event.initCustomEvent("SSTabRestored", true, false, { + isRemotenessUpdate: aIsRemotenessUpdate, + }); + aTab.dispatchEvent(event); + }, + + /** + * @param aWindow + * Window reference + * @returns whether this window's data is still cached in _statesToRestore + * because it's not fully loaded yet + */ + _isWindowLoaded: function ssi_isWindowLoaded(aWindow) { + return !WINDOW_RESTORE_IDS.has(aWindow); + }, + + /** + * Resize this._closedWindows to the value of the pref, except in the case + * where we don't have any non-popup windows on Windows and Linux. Then we must + * resize such that we have at least one non-popup window. + */ + _capClosedWindows: function ssi_capClosedWindows() { + if (this._closedWindows.length <= this._max_windows_undo) { + return; + } + let spliceTo = this._max_windows_undo; + if (AppConstants.platform != "macosx") { + let normalWindowIndex = 0; + // try to find a non-popup window in this._closedWindows + while ( + normalWindowIndex < this._closedWindows.length && + !!this._closedWindows[normalWindowIndex].isPopup + ) { + normalWindowIndex++; + } + if (normalWindowIndex >= this._max_windows_undo) { + spliceTo = normalWindowIndex + 1; + } + } + if (spliceTo < this._closedWindows.length) { + this._closedWindows.splice(spliceTo, this._closedWindows.length); + this._closedObjectsChanged = true; + } + }, + + /** + * Clears the set of windows that are "resurrected" before writing to disk to + * make closing windows one after the other until shutdown work as expected. + * + * This function should only be called when we are sure that there has been + * a user action that indicates the browser is actively being used and all + * windows that have been closed before are not part of a series of closing + * windows. + */ + _clearRestoringWindows: function ssi_clearRestoringWindows() { + for (let i = 0; i < this._closedWindows.length; i++) { + delete this._closedWindows[i]._shouldRestore; + } + }, + + /** + * Reset state to prepare for a new session state to be restored. + */ + _resetRestoringState: function ssi_initRestoringState() { + TabRestoreQueue.reset(); + this._tabsRestoringCount = 0; + }, + + /** + * Reset the restoring state for a particular tab. This will be called when + * removing a tab or when a tab needs to be reset (it's being overwritten). + * + * @param aTab + * The tab that will be "reset" + */ + _resetLocalTabRestoringState(aTab) { + let browser = aTab.linkedBrowser; + + // Keep the tab's previous state for later in this method + let previousState = TAB_STATE_FOR_BROWSER.get(browser); + + if (!previousState) { + console.error("Given tab is not restoring."); + return; + } + + // The browser is no longer in any sort of restoring state. + TAB_STATE_FOR_BROWSER.delete(browser); + + if (Services.appinfo.sessionHistoryInParent) { + this._restoreListeners.get(browser.permanentKey)?.unregister(); + browser.browsingContext.clearRestoreState(); + } + + aTab.removeAttribute("pending"); + + if (previousState == TAB_STATE_RESTORING) { + if (this._tabsRestoringCount) { + this._tabsRestoringCount--; + } + } else if (previousState == TAB_STATE_NEEDS_RESTORE) { + // Make sure that the tab is removed from the list of tabs to restore. + // Again, this is normally done in restoreTabContent, but that isn't being called + // for this tab. + TabRestoreQueue.remove(aTab); + } + }, + + _resetTabRestoringState(tab) { + let browser = tab.linkedBrowser; + + if (!TAB_STATE_FOR_BROWSER.has(browser)) { + console.error("Given tab is not restoring."); + return; + } + + if (!Services.appinfo.sessionHistoryInParent) { + browser.messageManager.sendAsyncMessage("SessionStore:resetRestore", {}); + } + this._resetLocalTabRestoringState(tab); + }, + + /** + * Each fresh tab starts out with epoch=0. This function can be used to + * start a next epoch by incrementing the current value. It will enables us + * to ignore stale messages sent from previous epochs. The function returns + * the new epoch ID for the given |browser|. + */ + startNextEpoch(permanentKey) { + let next = this.getCurrentEpoch(permanentKey) + 1; + this._browserEpochs.set(permanentKey, next); + return next; + }, + + /** + * Returns the current epoch for the given . If we haven't assigned + * a new epoch this will default to zero for new tabs. + */ + getCurrentEpoch(permanentKey) { + return this._browserEpochs.get(permanentKey) || 0; + }, + + /** + * Each time a element is restored, we increment its "epoch". To + * check if a message from content-sessionStore.js is out of date, we can + * compare the epoch received with the message to the element's + * epoch. This function does that, and returns true if |epoch| is up-to-date + * with respect to |browser|. + */ + isCurrentEpoch(permanentKey, epoch) { + return this.getCurrentEpoch(permanentKey) == epoch; + }, + + /** + * Resets the epoch for a given . We need to this every time we + * receive a hint that a new docShell has been loaded into the browser as + * the frame script starts out with epoch=0. + */ + resetEpoch(permanentKey, frameLoader = null) { + this._browserEpochs.delete(permanentKey); + if (frameLoader) { + frameLoader.requestEpochUpdate(0); + } + }, + + /** + * Countdown for a given duration, skipping beats if the computer is too busy, + * sleeping or otherwise unavailable. + * + * @param {number} delay An approximate delay to wait in milliseconds (rounded + * up to the closest second). + * + * @return Promise + */ + looseTimer(delay) { + let DELAY_BEAT = 1000; + let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + let beats = Math.ceil(delay / DELAY_BEAT); + let deferred = lazy.PromiseUtils.defer(); + timer.initWithCallback( + function () { + if (beats <= 0) { + deferred.resolve(); + } + --beats; + }, + DELAY_BEAT, + Ci.nsITimer.TYPE_REPEATING_PRECISE_CAN_SKIP + ); + // Ensure that the timer is both canceled once we are done with it + // and not garbage-collected until then. + deferred.promise.then( + () => timer.cancel(), + () => timer.cancel() + ); + return deferred; + }, + + /** + * Builds a single nsISessionStoreRestoreData tree for the provided |formdata| + * and |scroll| trees. + */ + buildRestoreData(formdata, scroll) { + function addFormEntries(root, fields, isXpath) { + for (let [key, value] of Object.entries(fields)) { + switch (typeof value) { + case "string": + root.addTextField(isXpath, key, value); + break; + case "boolean": + root.addCheckbox(isXpath, key, value); + break; + case "object": { + if (value === null) { + break; + } + if ( + value.hasOwnProperty("type") && + value.hasOwnProperty("fileList") + ) { + root.addFileList(isXpath, key, value.type, value.fileList); + break; + } + if ( + value.hasOwnProperty("selectedIndex") && + value.hasOwnProperty("value") + ) { + root.addSingleSelect( + isXpath, + key, + value.selectedIndex, + value.value + ); + break; + } + if ( + key === "sessionData" && + ["about:sessionrestore", "about:welcomeback"].includes( + formdata.url + ) + ) { + root.addTextField(isXpath, key, JSON.stringify(value)); + break; + } + if (Array.isArray(value)) { + root.addMultipleSelect(isXpath, key, value); + break; + } + } + } + } + } + + let root = SessionStoreUtils.constructSessionStoreRestoreData(); + if (scroll?.hasOwnProperty("scroll")) { + root.scroll = scroll.scroll; + } + if (formdata?.hasOwnProperty("url")) { + root.url = formdata.url; + if (formdata.hasOwnProperty("innerHTML")) { + // eslint-disable-next-line no-unsanitized/property + root.innerHTML = formdata.innerHTML; + } + if (formdata.hasOwnProperty("xpath")) { + addFormEntries(root, formdata.xpath, /* isXpath */ true); + } + if (formdata.hasOwnProperty("id")) { + addFormEntries(root, formdata.id, /* isXpath */ false); + } + } + let childrenLength = Math.max( + scroll?.children?.length || 0, + formdata?.children?.length || 0 + ); + for (let i = 0; i < childrenLength; i++) { + root.addChild( + this.buildRestoreData(formdata?.children?.[i], scroll?.children?.[i]), + i + ); + } + return root; + }, + + _waitForStateStop(browser, expectedURL = null) { + const deferred = lazy.PromiseUtils.defer(); + + const listener = { + unregister(reject = true) { + if (reject) { + deferred.reject(); + } + + SessionStoreInternal._restoreListeners.delete(browser.permanentKey); + + try { + browser.removeProgressListener( + this, + Ci.nsIWebProgress.NOTIFY_STATE_WINDOW + ); + } catch {} // May have already gotten rid of the browser's webProgress. + }, + + onStateChange(webProgress, request, stateFlags, status) { + if ( + webProgress.isTopLevel && + stateFlags & Ci.nsIWebProgressListener.STATE_IS_WINDOW && + stateFlags & Ci.nsIWebProgressListener.STATE_STOP + ) { + // FIXME: We sometimes see spurious STATE_STOP events for about:blank + // loads, so we have to account for that here. + let aboutBlankOK = !expectedURL || expectedURL === "about:blank"; + let url = request.QueryInterface(Ci.nsIChannel).originalURI.spec; + if (url !== "about:blank" || aboutBlankOK) { + this.unregister(false); + deferred.resolve(); + } + } + }, + + QueryInterface: ChromeUtils.generateQI([ + "nsIWebProgressListener", + "nsISupportsWeakReference", + ]), + }; + + this._restoreListeners.get(browser.permanentKey)?.unregister(); + this._restoreListeners.set(browser.permanentKey, listener); + + browser.addProgressListener( + listener, + Ci.nsIWebProgress.NOTIFY_STATE_WINDOW + ); + + return deferred.promise; + }, + + _listenForNavigations(browser, callbacks) { + const listener = { + unregister() { + browser.browsingContext?.sessionHistory?.removeSHistoryListener(this); + + try { + browser.removeProgressListener( + this, + Ci.nsIWebProgress.NOTIFY_STATE_WINDOW + ); + } catch {} // May have already gotten rid of the browser's webProgress. + + SessionStoreInternal._restoreListeners.delete(browser.permanentKey); + }, + + OnHistoryReload() { + this.unregister(); + return callbacks.onHistoryReload(); + }, + + // TODO(kashav): ContentRestore.sys.mjs handles OnHistoryNewEntry + // separately, so we should eventually support that here as well. + OnHistoryNewEntry() {}, + OnHistoryGotoIndex() {}, + OnHistoryPurge() {}, + OnHistoryReplaceEntry() {}, + + onStateChange(webProgress, request, stateFlags, status) { + if ( + webProgress.isTopLevel && + stateFlags & Ci.nsIWebProgressListener.STATE_IS_WINDOW && + stateFlags & Ci.nsIWebProgressListener.STATE_START + ) { + this.unregister(); + callbacks.onStartRequest(); + } + }, + + QueryInterface: ChromeUtils.generateQI([ + "nsISHistoryListener", + "nsIWebProgressListener", + "nsISupportsWeakReference", + ]), + }; + + this._restoreListeners.get(browser.permanentKey)?.unregister(); + this._restoreListeners.set(browser.permanentKey, listener); + + browser.browsingContext?.sessionHistory?.addSHistoryListener(listener); + + browser.addProgressListener( + listener, + Ci.nsIWebProgress.NOTIFY_STATE_WINDOW + ); + }, + + /** + * This mirrors ContentRestore.restoreHistory() for parent process session + * history restores. + */ + _restoreHistory(browser, data) { + if (!Services.appinfo.sessionHistoryInParent) { + throw new Error("This function should only be used with SHIP"); + } + + this._tabStateToRestore.set(browser.permanentKey, data); + + // In case about:blank isn't done yet. + // XXX(kashav): Does this actually accomplish anything? Can we remove? + browser.stop(); + + lazy.SessionHistory.restoreFromParent( + browser.browsingContext.sessionHistory, + data.tabData + ); + + let url = data.tabData?.entries[data.tabData.index - 1]?.url; + let disallow = data.tabData?.disallow; + + let promise = SessionStoreUtils.restoreDocShellState( + browser.browsingContext, + url, + disallow + ); + this._tabStateRestorePromises.set(browser.permanentKey, promise); + + const onResolve = () => { + if (TAB_STATE_FOR_BROWSER.get(browser) !== TAB_STATE_RESTORING) { + this._listenForNavigations(browser, { + // The history entry was reloaded before we began restoring tab + // content, just proceed as we would normally. + onHistoryReload: () => { + this._restoreTabContent(browser); + return false; + }, + + // Some foreign code, like an extension, loaded a new URI on the + // browser. We no longer want to restore saved tab data, but may + // still have browser state that needs to be restored. + onStartRequest: () => { + this._tabStateToRestore.delete(browser.permanentKey); + this._restoreTabContent(browser); + }, + }); + } + + this._tabStateRestorePromises.delete(browser.permanentKey); + + this._restoreHistoryComplete(browser, data); + }; + + promise.then(onResolve).catch(() => {}); + }, + + /** + * Either load the saved typed value or restore the active history entry. + * If neither is possible, just load an empty document. + */ + _restoreTabEntry(browser, tabData) { + let haveUserTypedValue = tabData.userTypedValue && tabData.userTypedClear; + // First take care of the common case where we load the history entry. + if (!haveUserTypedValue && tabData.entries.length) { + return SessionStoreUtils.initializeRestore( + browser.browsingContext, + this.buildRestoreData(tabData.formdata, tabData.scroll) + ); + } + // Here, we need to load user data or about:blank instead. + // As it's user-typed (or blank), it gets system triggering principal: + let triggeringPrincipal = + Services.scriptSecurityManager.getSystemPrincipal(); + // Bypass all the fixup goop for about:blank: + if (!haveUserTypedValue) { + let blankPromise = this._waitForStateStop(browser, "about:blank"); + browser.browsingContext.loadURI(lazy.blankURI, { + triggeringPrincipal, + loadFlags: Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_HISTORY, + }); + return blankPromise; + } + + // We have a user typed value, load that with fixup: + let loadPromise = this._waitForStateStop(browser, tabData.userTypedValue); + browser.browsingContext.fixupAndLoadURIString(tabData.userTypedValue, { + loadFlags: Ci.nsIWebNavigation.LOAD_FLAGS_ALLOW_THIRD_PARTY_FIXUP, + triggeringPrincipal, + }); + + return loadPromise; + }, + + /** + * This mirrors ContentRestore.restoreTabContent() for parent process session + * history restores. + */ + _restoreTabContent(browser, options = {}) { + if (!Services.appinfo.sessionHistoryInParent) { + throw new Error("This function should only be used with SHIP"); + } + + this._restoreListeners.get(browser.permanentKey)?.unregister(); + + this._restoreTabContentStarted(browser, options); + + let state = this._tabStateToRestore.get(browser.permanentKey); + this._tabStateToRestore.delete(browser.permanentKey); + + let promises = [this._tabStateRestorePromises.get(browser.permanentKey)]; + + if (state) { + promises.push(this._restoreTabEntry(browser, state.tabData)); + } else { + // The browser started another load, so we decided to not restore + // saved tab data. We should still wait for that new load to finish + // before proceeding. + promises.push(this._waitForStateStop(browser)); + } + + Promise.allSettled(promises).then(() => { + this._restoreTabContentComplete(browser, options); + }); + }, + + _sendRestoreTabContent(browser, options) { + if (Services.appinfo.sessionHistoryInParent) { + this._restoreTabContent(browser, options); + } else { + browser.messageManager.sendAsyncMessage( + "SessionStore:restoreTabContent", + options + ); + } + }, + + _restoreHistoryComplete(browser, data) { + let win = browser.ownerGlobal; + let tab = win?.gBrowser.getTabForBrowser(browser); + if (!tab) { + return; + } + + // Notify the tabbrowser that the tab chrome has been restored. + let tabData = lazy.TabState.collect(tab, TAB_CUSTOM_VALUES.get(tab)); + + // Update tab label and icon again after the tab history was updated. + this.updateTabLabelAndIcon(tab, tabData); + + let event = win.document.createEvent("Events"); + event.initEvent("SSTabRestoring", true, false); + tab.dispatchEvent(event); + }, + + _restoreTabContentStarted(browser, data) { + let win = browser.ownerGlobal; + let tab = win?.gBrowser.getTabForBrowser(browser); + if (!tab) { + return; + } + + let initiatedBySessionStore = + TAB_STATE_FOR_BROWSER.get(browser) != TAB_STATE_NEEDS_RESTORE; + let isNavigateAndRestore = + data.reason == RESTORE_TAB_CONTENT_REASON.NAVIGATE_AND_RESTORE; + + // We need to be careful when restoring the urlbar's search mode because + // we race a call to gURLBar.setURI due to the location change. setURI + // will exit search mode and set gURLBar.value to the restored URL, + // clobbering any search mode and userTypedValue we restore here. If + // this is a typical restore -- restoring on startup or restoring a + // closed tab for example -- then we need to restore search mode after + // that setURI call, and so we wait until restoreTabContentComplete, at + // which point setURI will have been called. If this is not a typical + // restore -- it was not initiated by session store or it's due to a + // remoteness change -- then we do not want to restore search mode at + // all, and so we remove it from the tab state cache. In particular, if + // the restore is due to a remoteness change, then the user is loading a + // new URL and the current search mode should not be carried over to it. + let cacheState = lazy.TabStateCache.get(browser.permanentKey); + if (cacheState.searchMode) { + if (!initiatedBySessionStore || isNavigateAndRestore) { + lazy.TabStateCache.update(browser.permanentKey, { + searchMode: null, + userTypedValue: null, + }); + } + return; + } + + if (!initiatedBySessionStore) { + // If a load not initiated by sessionstore was started in a + // previously pending tab. Mark the tab as no longer pending. + this.markTabAsRestoring(tab); + } else if (!isNavigateAndRestore) { + // If the user was typing into the URL bar when we crashed, but hadn't hit + // enter yet, then we just need to write that value to the URL bar without + // loading anything. This must happen after the load, as the load will clear + // userTypedValue. + // + // Note that we only want to do that if we're restoring state for reasons + // _other_ than a navigateAndRestore remoteness-flip, as such a flip + // implies that the user was navigating. + let tabData = lazy.TabState.collect(tab, TAB_CUSTOM_VALUES.get(tab)); + if ( + tabData.userTypedValue && + !tabData.userTypedClear && + !browser.userTypedValue + ) { + browser.userTypedValue = tabData.userTypedValue; + if (tab.selected) { + win.gURLBar.setURI(); + } + } + + // Remove state we don't need any longer. + lazy.TabStateCache.update(browser.permanentKey, { + userTypedValue: null, + userTypedClear: null, + }); + } + }, + + _restoreTabContentComplete(browser, data) { + let win = browser.ownerGlobal; + let tab = browser.ownerGlobal?.gBrowser.getTabForBrowser(browser); + if (!tab) { + return; + } + // Restore search mode and its search string in userTypedValue, if + // appropriate. + let cacheState = lazy.TabStateCache.get(browser.permanentKey); + if (cacheState.searchMode) { + win.gURLBar.setSearchMode(cacheState.searchMode, browser); + browser.userTypedValue = cacheState.userTypedValue; + if (tab.selected) { + win.gURLBar.setURI(); + } + lazy.TabStateCache.update(browser.permanentKey, { + searchMode: null, + userTypedValue: null, + }); + } + + // This callback is used exclusively by tests that want to + // monitor the progress of network loads. + if (gDebuggingEnabled) { + Services.obs.notifyObservers(browser, NOTIFY_TAB_RESTORED); + } + + SessionStoreInternal._resetLocalTabRestoringState(tab); + SessionStoreInternal.restoreNextTab(); + + this._sendTabRestoredNotification(tab, data.isRemotenessUpdate); + + Services.obs.notifyObservers(null, "sessionstore-one-or-no-tab-restored"); + }, + + /** + * Send the "SessionStore:restoreHistory" message to content, triggering a + * content restore. This method is intended to be used internally by + * SessionStore, as it also ensures that permissions are avaliable in the + * content process before triggering the history restore in the content + * process. + * + * @param browser The browser to transmit the permissions for + * @param options The options data to send to content. + */ + _sendRestoreHistory(browser, options) { + if (options.tabData.storage) { + SessionStoreUtils.restoreSessionStorageFromParent( + browser.browsingContext, + options.tabData.storage + ); + delete options.tabData.storage; + } + + if (Services.appinfo.sessionHistoryInParent) { + this._restoreHistory(browser, options); + } else { + browser.messageManager.sendAsyncMessage( + "SessionStore:restoreHistory", + options + ); + } + + if (browser && browser.frameLoader) { + browser.frameLoader.requestEpochUpdate(options.epoch); + } + }, + + // Flush out session history state so that it can be used to restore the state + // into a new process in `finishTabRemotenessChange`. + // + // NOTE: This codepath is temporary while the Fission Session History rewrite + // is in process, and will be removed & replaced once that rewrite is + // complete. (bug 1645062) + async prepareToChangeRemoteness(aBrowser) { + aBrowser.messageManager.sendAsyncMessage( + "SessionStore:prepareForProcessChange" + ); + await lazy.TabStateFlusher.flush(aBrowser); + }, + + // Handle finishing the remoteness change for a tab by restoring session + // history state into it, and resuming the ongoing network load. + // + // NOTE: This codepath is temporary while the Fission Session History rewrite + // is in process, and will be removed & replaced once that rewrite is + // complete. (bug 1645062) + finishTabRemotenessChange(aTab, aSwitchId) { + let window = aTab.ownerGlobal; + if (!window || !window.__SSi || window.closed) { + return; + } + + let tabState = lazy.TabState.clone(aTab, TAB_CUSTOM_VALUES.get(aTab)); + let options = { + restoreImmediately: true, + restoreContentReason: RESTORE_TAB_CONTENT_REASON.NAVIGATE_AND_RESTORE, + isRemotenessUpdate: true, + loadArguments: { + redirectLoadSwitchId: aSwitchId, + // As we're resuming a load which has been redirected from another + // process, record the history index which is currently being requested. + // It has to be offset by 1 to get back to native history indices from + // SessionStore history indicies. + redirectHistoryIndex: tabState.requestedIndex - 1, + }, + }; + + // Need to reset restoring tabs. + if (TAB_STATE_FOR_BROWSER.has(aTab.linkedBrowser)) { + this._resetLocalTabRestoringState(aTab); + } + + // Restore the state into the tab. + this.restoreTab(aTab, tabState, options); + }, +}; + +/** + * Priority queue that keeps track of a list of tabs to restore and returns + * the tab we should restore next, based on priority rules. We decide between + * pinned, visible and hidden tabs in that and FIFO order. Hidden tabs are only + * restored with restore_hidden_tabs=true. + */ +var TabRestoreQueue = { + // The separate buckets used to store tabs. + tabs: { priority: [], visible: [], hidden: [] }, + + // Preferences used by the TabRestoreQueue to determine which tabs + // are restored automatically and which tabs will be on-demand. + prefs: { + // Lazy getter that returns whether tabs are restored on demand. + get restoreOnDemand() { + let updateValue = () => { + let value = Services.prefs.getBoolPref(PREF); + let definition = { value, configurable: true }; + Object.defineProperty(this, "restoreOnDemand", definition); + return value; + }; + + const PREF = "browser.sessionstore.restore_on_demand"; + Services.prefs.addObserver(PREF, updateValue); + return updateValue(); + }, + + // Lazy getter that returns whether pinned tabs are restored on demand. + get restorePinnedTabsOnDemand() { + let updateValue = () => { + let value = Services.prefs.getBoolPref(PREF); + let definition = { value, configurable: true }; + Object.defineProperty(this, "restorePinnedTabsOnDemand", definition); + return value; + }; + + const PREF = "browser.sessionstore.restore_pinned_tabs_on_demand"; + Services.prefs.addObserver(PREF, updateValue); + return updateValue(); + }, + + // Lazy getter that returns whether we should restore hidden tabs. + get restoreHiddenTabs() { + let updateValue = () => { + let value = Services.prefs.getBoolPref(PREF); + let definition = { value, configurable: true }; + Object.defineProperty(this, "restoreHiddenTabs", definition); + return value; + }; + + const PREF = "browser.sessionstore.restore_hidden_tabs"; + Services.prefs.addObserver(PREF, updateValue); + return updateValue(); + }, + }, + + // Resets the queue and removes all tabs. + reset() { + this.tabs = { priority: [], visible: [], hidden: [] }; + }, + + // Adds a tab to the queue and determines its priority bucket. + add(tab) { + let { priority, hidden, visible } = this.tabs; + + if (tab.pinned) { + priority.push(tab); + } else if (tab.hidden) { + hidden.push(tab); + } else { + visible.push(tab); + } + }, + + // Removes a given tab from the queue, if it's in there. + remove(tab) { + let { priority, hidden, visible } = this.tabs; + + // We'll always check priority first since we don't + // have an indicator if a tab will be there or not. + let set = priority; + let index = set.indexOf(tab); + + if (index == -1) { + set = tab.hidden ? hidden : visible; + index = set.indexOf(tab); + } + + if (index > -1) { + set.splice(index, 1); + } + }, + + // Returns and removes the tab with the highest priority. + shift() { + let set; + let { priority, hidden, visible } = this.tabs; + + let { restoreOnDemand, restorePinnedTabsOnDemand } = this.prefs; + let restorePinned = !(restoreOnDemand && restorePinnedTabsOnDemand); + if (restorePinned && priority.length) { + set = priority; + } else if (!restoreOnDemand) { + if (visible.length) { + set = visible; + } else if (this.prefs.restoreHiddenTabs && hidden.length) { + set = hidden; + } + } + + return set && set.shift(); + }, + + // Moves a given tab from the 'hidden' to the 'visible' bucket. + hiddenToVisible(tab) { + let { hidden, visible } = this.tabs; + let index = hidden.indexOf(tab); + + if (index > -1) { + hidden.splice(index, 1); + visible.push(tab); + } + }, + + // Moves a given tab from the 'visible' to the 'hidden' bucket. + visibleToHidden(tab) { + let { visible, hidden } = this.tabs; + let index = visible.indexOf(tab); + + if (index > -1) { + visible.splice(index, 1); + hidden.push(tab); + } + }, + + /** + * Returns true if the passed tab is in one of the sets that we're + * restoring content in automatically. + * + * @param tab () + * The tab to check + * @returns bool + */ + willRestoreSoon(tab) { + let { priority, hidden, visible } = this.tabs; + let { restoreOnDemand, restorePinnedTabsOnDemand, restoreHiddenTabs } = + this.prefs; + let restorePinned = !(restoreOnDemand && restorePinnedTabsOnDemand); + let candidateSet = []; + + if (restorePinned && priority.length) { + candidateSet.push(...priority); + } + + if (!restoreOnDemand) { + if (visible.length) { + candidateSet.push(...visible); + } + + if (restoreHiddenTabs && hidden.length) { + candidateSet.push(...hidden); + } + } + + return candidateSet.indexOf(tab) > -1; + }, +}; + +// A map storing a closed window's state data until it goes aways (is GC'ed). +// This ensures that API clients can still read (but not write) states of +// windows they still hold a reference to but we don't. +var DyingWindowCache = { + _data: new WeakMap(), + + has(window) { + return this._data.has(window); + }, + + get(window) { + return this._data.get(window); + }, + + set(window, data) { + this._data.set(window, data); + }, + + remove(window) { + this._data.delete(window); + }, +}; + +// A weak set of dirty windows. We use it to determine which windows we need to +// recollect data for when getCurrentState() is called. +var DirtyWindows = { + _data: new WeakMap(), + + has(window) { + return this._data.has(window); + }, + + add(window) { + return this._data.set(window, true); + }, + + remove(window) { + this._data.delete(window); + }, + + clear(window) { + this._data = new WeakMap(); + }, +}; + +// The state from the previous session (after restoring pinned tabs). This +// state is persisted and passed through to the next session during an app +// restart to make the third party add-on warning not trash the deferred +// session +var LastSession = { + _state: null, + + get canRestore() { + return !!this._state; + }, + + getState() { + return this._state; + }, + + setState(state) { + this._state = state; + }, + + clear(silent = false) { + if (this._state) { + this._state = null; + if (!silent) { + Services.obs.notifyObservers(null, NOTIFY_LAST_SESSION_CLEARED); + } + } + }, +}; + +// Exposed for tests +export const _LastSession = LastSession; diff --git a/browser/components/sessionstore/SessionWriter.sys.mjs b/browser/components/sessionstore/SessionWriter.sys.mjs new file mode 100644 index 0000000000..37f565e4af --- /dev/null +++ b/browser/components/sessionstore/SessionWriter.sys.mjs @@ -0,0 +1,396 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * We just started (we haven't written anything to disk yet) from + * `Paths.clean`. The backup directory may not exist. + */ +const STATE_CLEAN = "clean"; +/** + * We know that `Paths.recovery` is good, either because we just read + * it (we haven't written anything to disk yet) or because have + * already written once to `Paths.recovery` during this session. + * `Paths.clean` is absent or invalid. The backup directory exists. + */ +const STATE_RECOVERY = "recovery"; +/** + * We just started from `Paths.upgradeBackup` (we haven't written + * anything to disk yet). Both `Paths.clean`, `Paths.recovery` and + * `Paths.recoveryBackup` are absent or invalid. The backup directory + * exists. + */ +const STATE_UPGRADE_BACKUP = "upgradeBackup"; +/** + * We just started without a valid session store file (we haven't + * written anything to disk yet). The backup directory may not exist. + */ +const STATE_EMPTY = "empty"; + +var sessionFileIOMutex = Promise.resolve(); +// Ensure that we don't do concurrent I/O on the same file. +// Example usage: +// const unlock = await lockIOWithMutex(); +// try { +// ... (Do I/O work here.) +// } finally { unlock(); } +function lockIOWithMutex() { + // Return a Promise that resolves when the mutex is free. + return new Promise(unlock => { + // Overwrite the mutex variable with a chained-on, new Promise. The Promise + // we returned to the caller can be called to resolve that new Promise + // and unlock the mutex. + sessionFileIOMutex = sessionFileIOMutex.then(() => { + return new Promise(unlock); + }); + }); +} + +/** + * Interface dedicated to handling I/O for Session Store. + */ +export const SessionWriter = { + init(origin, useOldExtension, paths, prefs = {}) { + return SessionWriterInternal.init(origin, useOldExtension, paths, prefs); + }, + + /** + * Write the contents of the session file. + * @param state - May get changed on shutdown. + */ + async write(state, options = {}) { + const unlock = await lockIOWithMutex(); + try { + return await SessionWriterInternal.write(state, options); + } finally { + unlock(); + } + }, + + async wipe() { + const unlock = await lockIOWithMutex(); + try { + return await SessionWriterInternal.wipe(); + } finally { + unlock(); + } + }, +}; + +const SessionWriterInternal = { + // Path to the files used by the SessionWriter + Paths: null, + + /** + * The current state of the session file, as one of the following strings: + * - "empty" if we have started without any sessionstore; + * - one of "clean", "recovery", "recoveryBackup", "cleanBackup", + * "upgradeBackup" if we have started by loading the corresponding file. + */ + state: null, + + /** + * A flag that indicates we loaded a session file with the deprecated .js extension. + */ + useOldExtension: false, + + /** + * Number of old upgrade backups that are being kept + */ + maxUpgradeBackups: null, + + /** + * Initialize (or reinitialize) the writer. + * + * @param {string} origin Which of sessionstore.js or its backups + * was used. One of the `STATE_*` constants defined above. + * @param {boolean} a flag indicate whether we loaded a session file with ext .js + * @param {object} paths The paths at which to find the various files. + * @param {object} prefs The preferences the writer needs to know. + */ + init(origin, useOldExtension, paths, prefs) { + if (!(origin in paths || origin == STATE_EMPTY)) { + throw new TypeError("Invalid origin: " + origin); + } + + // Check that all required preference values were passed. + for (let pref of [ + "maxUpgradeBackups", + "maxSerializeBack", + "maxSerializeForward", + ]) { + if (!prefs.hasOwnProperty(pref)) { + throw new TypeError(`Missing preference value for ${pref}`); + } + } + + this.useOldExtension = useOldExtension; + this.state = origin; + this.Paths = paths; + this.maxUpgradeBackups = prefs.maxUpgradeBackups; + this.maxSerializeBack = prefs.maxSerializeBack; + this.maxSerializeForward = prefs.maxSerializeForward; + this.upgradeBackupNeeded = paths.nextUpgradeBackup != paths.upgradeBackup; + return { result: true }; + }, + + /** + * Write the session to disk. + * Write the session to disk, performing any necessary backup + * along the way. + * + * @param {object} state The state to write to disk. + * @param {object} options + * - performShutdownCleanup If |true|, we should + * perform shutdown-time cleanup to ensure that private data + * is not left lying around; + * - isFinalWrite If |true|, write to Paths.clean instead of + * Paths.recovery + */ + async write(state, options) { + let exn; + let telemetry = {}; + + // Cap the number of backward and forward shistory entries on shutdown. + if (options.isFinalWrite) { + for (let window of state.windows) { + for (let tab of window.tabs) { + let lower = 0; + let upper = tab.entries.length; + + if (this.maxSerializeBack > -1) { + lower = Math.max(lower, tab.index - this.maxSerializeBack - 1); + } + if (this.maxSerializeForward > -1) { + upper = Math.min(upper, tab.index + this.maxSerializeForward); + } + + tab.entries = tab.entries.slice(lower, upper); + tab.index -= lower; + } + } + } + + try { + if (this.state == STATE_CLEAN || this.state == STATE_EMPTY) { + // The backups directory may not exist yet. In all other cases, + // we have either already read from or already written to this + // directory, so we are satisfied that it exists. + await IOUtils.makeDirectory(this.Paths.backups); + } + + if (this.state == STATE_CLEAN) { + // Move $Path.clean out of the way, to avoid any ambiguity as + // to which file is more recent. + if (!this.useOldExtension) { + await IOUtils.move(this.Paths.clean, this.Paths.cleanBackup); + } else { + // Since we are migrating from .js to .jsonlz4, + // we need to compress the deprecated $Path.clean + // and write it to $Path.cleanBackup. + let oldCleanPath = this.Paths.clean.replace("jsonlz4", "js"); + let d = await IOUtils.read(oldCleanPath); + await IOUtils.write(this.Paths.cleanBackup, d, { compress: true }); + } + } + + let startWriteMs = Date.now(); + let fileStat; + + if (options.isFinalWrite) { + // We are shutting down. At this stage, we know that + // $Paths.clean is either absent or corrupted. If it was + // originally present and valid, it has been moved to + // $Paths.cleanBackup a long time ago. We can therefore write + // with the guarantees that we erase no important data. + await IOUtils.writeJSON(this.Paths.clean, state, { + tmpPath: this.Paths.clean + ".tmp", + compress: true, + }); + fileStat = await IOUtils.stat(this.Paths.clean); + } else if (this.state == STATE_RECOVERY) { + // At this stage, either $Paths.recovery was written >= 15 + // seconds ago during this session or we have just started + // from $Paths.recovery left from the previous session. Either + // way, $Paths.recovery is good. We can move $Path.backup to + // $Path.recoveryBackup without erasing a good file with a bad + // file. + await IOUtils.writeJSON(this.Paths.recovery, state, { + tmpPath: this.Paths.recovery + ".tmp", + backupFile: this.Paths.recoveryBackup, + compress: true, + }); + fileStat = await IOUtils.stat(this.Paths.recovery); + } else { + // In other cases, either $Path.recovery is not necessary, or + // it doesn't exist or it has been corrupted. Regardless, + // don't backup $Path.recovery. + await IOUtils.writeJSON(this.Paths.recovery, state, { + tmpPath: this.Paths.recovery + ".tmp", + compress: true, + }); + fileStat = await IOUtils.stat(this.Paths.recovery); + } + + telemetry.FX_SESSION_RESTORE_WRITE_FILE_MS = Date.now() - startWriteMs; + telemetry.FX_SESSION_RESTORE_FILE_SIZE_BYTES = fileStat.size; + } catch (ex) { + // Don't throw immediately + exn = exn || ex; + } + + // If necessary, perform an upgrade backup + let upgradeBackupComplete = false; + if ( + this.upgradeBackupNeeded && + (this.state == STATE_CLEAN || this.state == STATE_UPGRADE_BACKUP) + ) { + try { + // If we loaded from `clean`, the file has since then been renamed to `cleanBackup`. + let path = + this.state == STATE_CLEAN + ? this.Paths.cleanBackup + : this.Paths.upgradeBackup; + await IOUtils.copy(path, this.Paths.nextUpgradeBackup); + this.upgradeBackupNeeded = false; + upgradeBackupComplete = true; + } catch (ex) { + // Don't throw immediately + exn = exn || ex; + } + + // Find all backups + let backups = []; + + try { + let children = await IOUtils.getChildren(this.Paths.backups); + backups = children.filter(path => + path.startsWith(this.Paths.upgradeBackupPrefix) + ); + } catch (ex) { + // Don't throw immediately + exn = exn || ex; + } + + // If too many backups exist, delete them + if (backups.length > this.maxUpgradeBackups) { + // Use alphanumerical sort since dates are in YYYYMMDDHHMMSS format + backups.sort(); + // remove backup file if it is among the first (n-maxUpgradeBackups) files + for (let i = 0; i < backups.length - this.maxUpgradeBackups; i++) { + try { + await IOUtils.remove(backups[i]); + } catch (ex) { + exn = exn || ex; + } + } + } + } + + if (options.performShutdownCleanup && !exn) { + // During shutdown, if auto-restore is disabled, we need to + // remove possibly sensitive data that has been stored purely + // for crash recovery. Note that this slightly decreases our + // ability to recover from OS-level/hardware-level issue. + + // If an exception was raised, we assume that we still need + // these files. + await IOUtils.remove(this.Paths.recoveryBackup); + await IOUtils.remove(this.Paths.recovery); + } + + this.state = STATE_RECOVERY; + + if (exn) { + throw exn; + } + + return { + result: { + upgradeBackup: upgradeBackupComplete, + }, + telemetry, + }; + }, + + /** + * Wipes all files holding session data from disk. + */ + async wipe() { + // Don't stop immediately in case of error. + let exn = null; + + // Erase main session state file + try { + await IOUtils.remove(this.Paths.clean); + // Remove old extension ones. + let oldCleanPath = this.Paths.clean.replace("jsonlz4", "js"); + await IOUtils.remove(oldCleanPath, { + ignoreAbsent: true, + }); + } catch (ex) { + // Don't stop immediately. + exn = exn || ex; + } + + // Wipe the Session Restore directory + try { + await IOUtils.remove(this.Paths.backups, { recursive: true }); + } catch (ex) { + exn = exn || ex; + } + + // Wipe legacy Session Restore files from the profile directory + try { + await this._wipeFromDir(PathUtils.profileDir, "sessionstore.bak"); + } catch (ex) { + exn = exn || ex; + } + + this.state = STATE_EMPTY; + if (exn) { + throw exn; + } + + return { result: true }; + }, + + /** + * Wipe a number of files from a directory. + * + * @param {string} path The directory. + * @param {string} prefix Remove files whose + * name starts with the prefix. + */ + async _wipeFromDir(path, prefix) { + // Sanity check + if (!prefix) { + throw new TypeError("Must supply prefix"); + } + + let exn = null; + + let children = await IOUtils.getChildren(path, { + ignoreAbsent: true, + }); + for (let entryPath of children) { + if (!PathUtils.filename(entryPath).startsWith(prefix)) { + continue; + } + try { + let { type } = await IOUtils.stat(entryPath); + if (type == "directory") { + continue; + } + await IOUtils.remove(entryPath); + } catch (ex) { + // Don't stop immediately + exn = exn || ex; + } + } + + if (exn) { + throw exn; + } + }, +}; diff --git a/browser/components/sessionstore/StartupPerformance.sys.mjs b/browser/components/sessionstore/StartupPerformance.sys.mjs new file mode 100644 index 0000000000..a13333d9d1 --- /dev/null +++ b/browser/components/sessionstore/StartupPerformance.sys.mjs @@ -0,0 +1,242 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + clearTimeout: "resource://gre/modules/Timer.sys.mjs", + setTimeout: "resource://gre/modules/Timer.sys.mjs", +}); + +const COLLECT_RESULTS_AFTER_MS = 10000; + +const OBSERVED_TOPICS = [ + "sessionstore-restoring-on-startup", + "sessionstore-initiating-manual-restore", +]; + +export var StartupPerformance = { + /** + * Once we have finished restoring initial tabs, we broadcast on this topic. + */ + RESTORED_TOPIC: "sessionstore-finished-restoring-initial-tabs", + + // Instant at which we have started restoration (notification "sessionstore-restoring-on-startup") + _startTimeStamp: null, + + // Latest instant at which we have finished restoring a tab (DOM event "SSTabRestored") + _latestRestoredTimeStamp: null, + + // A promise resolved once we have finished restoring all the startup tabs. + _promiseFinished: null, + + // Function `resolve()` for `_promiseFinished`. + _resolveFinished: null, + + // A timer + _deadlineTimer: null, + + // `true` once the timer has fired + _hasFired: false, + + // `true` once we are restored + _isRestored: false, + + // Statistics on the session we need to restore. + _totalNumberOfEagerTabs: 0, + _totalNumberOfTabs: 0, + _totalNumberOfWindows: 0, + + init() { + for (let topic of OBSERVED_TOPICS) { + Services.obs.addObserver(this, topic); + } + }, + + /** + * Return the timestamp at which we finished restoring the latest tab. + * + * This information is not really interesting until we have finished restoring + * tabs. + */ + get latestRestoredTimeStamp() { + return this._latestRestoredTimeStamp; + }, + + /** + * `true` once we have finished restoring startup tabs. + */ + get isRestored() { + return this._isRestored; + }, + + // Called when restoration starts. + // Record the start timestamp, setup the timer and `this._promiseFinished`. + // Behavior is unspecified if there was already an ongoing measure. + _onRestorationStarts(isAutoRestore) { + ChromeUtils.addProfilerMarker("_onRestorationStarts"); + this._latestRestoredTimeStamp = this._startTimeStamp = Date.now(); + this._totalNumberOfEagerTabs = 0; + this._totalNumberOfTabs = 0; + this._totalNumberOfWindows = 0; + + // While we may restore several sessions in a single run of the browser, + // that's a very unusual case, and not really worth measuring, so let's + // stop listening for further restorations. + + for (let topic of OBSERVED_TOPICS) { + Services.obs.removeObserver(this, topic); + } + + Services.obs.addObserver(this, "sessionstore-single-window-restored"); + this._promiseFinished = new Promise(resolve => { + this._resolveFinished = resolve; + }); + this._promiseFinished.then(() => { + try { + this._isRestored = true; + Services.obs.notifyObservers(null, this.RESTORED_TOPIC); + + if (this._latestRestoredTimeStamp == this._startTimeStamp) { + // Apparently, we haven't restored any tab. + return; + } + + // Once we are done restoring tabs, update Telemetry. + let histogramName = isAutoRestore + ? "FX_SESSION_RESTORE_AUTO_RESTORE_DURATION_UNTIL_EAGER_TABS_RESTORED_MS" + : "FX_SESSION_RESTORE_MANUAL_RESTORE_DURATION_UNTIL_EAGER_TABS_RESTORED_MS"; + let histogram = Services.telemetry.getHistogramById(histogramName); + let delta = this._latestRestoredTimeStamp - this._startTimeStamp; + histogram.add(delta); + + Services.telemetry + .getHistogramById("FX_SESSION_RESTORE_NUMBER_OF_EAGER_TABS_RESTORED") + .add(this._totalNumberOfEagerTabs); + Services.telemetry + .getHistogramById("FX_SESSION_RESTORE_NUMBER_OF_TABS_RESTORED") + .add(this._totalNumberOfTabs); + Services.telemetry + .getHistogramById("FX_SESSION_RESTORE_NUMBER_OF_WINDOWS_RESTORED") + .add(this._totalNumberOfWindows); + + // Reset + this._startTimeStamp = null; + } catch (ex) { + console.error("StartupPerformance: error after resolving promise", ex); + } + }); + }, + + _startTimer() { + if (this._hasFired) { + return; + } + if (this._deadlineTimer) { + lazy.clearTimeout(this._deadlineTimer); + } + this._deadlineTimer = lazy.setTimeout(() => { + try { + this._resolveFinished(); + } catch (ex) { + console.error("StartupPerformance: Error in timeout handler", ex); + } finally { + // Clean up. + this._deadlineTimer = null; + this._hasFired = true; + this._resolveFinished = null; + Services.obs.removeObserver( + this, + "sessionstore-single-window-restored" + ); + } + }, COLLECT_RESULTS_AFTER_MS); + }, + + observe(subject, topic, details) { + try { + switch (topic) { + case "sessionstore-restoring-on-startup": + this._onRestorationStarts(true); + break; + case "sessionstore-initiating-manual-restore": + this._onRestorationStarts(false); + break; + case "sessionstore-single-window-restored": + { + // Session Restore has just opened a window with (initially empty) tabs. + // Some of these tabs will be restored eagerly, while others will be + // restored on demand. The process becomes usable only when all windows + // have finished restored their eager tabs. + // + // While it would be possible to track the restoration of each tab + // from within SessionRestore to determine exactly when the process + // becomes usable, experience shows that this is too invasive. Rather, + // we employ the following heuristic: + // - we maintain a timer of `COLLECT_RESULTS_AFTER_MS` that we expect + // will be triggered only once all tabs have been restored; + // - whenever we restore a new window (hence a bunch of eager tabs), + // we postpone the timer to ensure that the new eager tabs have + // `COLLECT_RESULTS_AFTER_MS` to be restored; + // - whenever a tab is restored, we update + // `this._latestRestoredTimeStamp`; + // - after `COLLECT_RESULTS_AFTER_MS`, we collect the final version + // of `this._latestRestoredTimeStamp`, and use it to determine the + // entire duration of the collection. + // + // Note that this heuristic may be inaccurate if a user clicks + // immediately on a restore-on-demand tab before the end of + // `COLLECT_RESULTS_AFTER_MS`. We assume that this will not + // affect too much the results. + // + // Reset the delay, to give the tabs a little (more) time to restore. + this._startTimer(); + + this._totalNumberOfWindows += 1; + + // Observe the restoration of all tabs. We assume that all tabs of this + // window will have been restored before `COLLECT_RESULTS_AFTER_MS`. + // The last call to `observer` will let us determine how long it took + // to reach that point. + let win = subject; + + let observer = event => { + // We don't care about tab restorations that are due to + // a browser flipping from out-of-main-process to in-main-process + // or vice-versa. We only care about restorations that are due + // to the user switching to a lazily restored tab, or for tabs + // that are restoring eagerly. + if (!event.detail.isRemotenessUpdate) { + ChromeUtils.addProfilerMarker("SSTabRestored"); + this._latestRestoredTimeStamp = Date.now(); + this._totalNumberOfEagerTabs += 1; + } + }; + win.gBrowser.tabContainer.addEventListener( + "SSTabRestored", + observer + ); + this._totalNumberOfTabs += win.gBrowser.tabContainer.itemCount; + + // Once we have finished collecting the results, clean up the observers. + this._promiseFinished.then(() => { + if (!win.gBrowser.tabContainer) { + // May be undefined during shutdown and/or some tests. + return; + } + win.gBrowser.tabContainer.removeEventListener( + "SSTabRestored", + observer + ); + }); + } + break; + default: + throw new Error(`Unexpected topic ${topic}`); + } + } catch (ex) { + console.error("StartupPerformance error", ex, ex.stack); + throw ex; + } + }, +}; diff --git a/browser/components/sessionstore/TabAttributes.sys.mjs b/browser/components/sessionstore/TabAttributes.sys.mjs new file mode 100644 index 0000000000..1c7f54b6ab --- /dev/null +++ b/browser/components/sessionstore/TabAttributes.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/. */ + +// We never want to directly read or write these attributes. +// 'image' should not be accessed directly but handled by using the +// gBrowser.getIcon()/setIcon() methods. +// 'muted' should not be accessed directly but handled by using the +// tab.linkedBrowser.audioMuted/toggleMuteAudio methods. +// 'pending' is used internal by sessionstore and managed accordingly. +const ATTRIBUTES_TO_SKIP = new Set([ + "image", + "muted", + "pending", + "skipbackgroundnotify", +]); + +// A set of tab attributes to persist. We will read a given list of tab +// attributes when collecting tab data and will re-set those attributes when +// the given tab data is restored to a new tab. +export var TabAttributes = Object.freeze({ + persist(name) { + return TabAttributesInternal.persist(name); + }, + + get(tab) { + return TabAttributesInternal.get(tab); + }, + + set(tab, data = {}) { + TabAttributesInternal.set(tab, data); + }, +}); + +var TabAttributesInternal = { + _attrs: new Set(), + + persist(name) { + if (this._attrs.has(name) || ATTRIBUTES_TO_SKIP.has(name)) { + return false; + } + + this._attrs.add(name); + return true; + }, + + get(tab) { + let data = {}; + + for (let name of this._attrs) { + if (tab.hasAttribute(name)) { + data[name] = tab.getAttribute(name); + } + } + + return data; + }, + + set(tab, data = {}) { + // Clear attributes. + for (let name of this._attrs) { + tab.removeAttribute(name); + } + + // Set attributes. + for (let [name, value] of Object.entries(data)) { + if (!ATTRIBUTES_TO_SKIP.has(name)) { + tab.setAttribute(name, value); + } + } + }, +}; diff --git a/browser/components/sessionstore/TabState.sys.mjs b/browser/components/sessionstore/TabState.sys.mjs new file mode 100644 index 0000000000..26f5671c84 --- /dev/null +++ b/browser/components/sessionstore/TabState.sys.mjs @@ -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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + PrivacyFilter: "resource://gre/modules/sessionstore/PrivacyFilter.sys.mjs", + TabAttributes: "resource:///modules/sessionstore/TabAttributes.sys.mjs", + TabStateCache: "resource:///modules/sessionstore/TabStateCache.sys.mjs", +}); + +/** + * Module that contains tab state collection methods. + */ +export var TabState = Object.freeze({ + update(permanentKey, data) { + TabStateInternal.update(permanentKey, data); + }, + + collect(tab, extData) { + return TabStateInternal.collect(tab, extData); + }, + + clone(tab, extData) { + return TabStateInternal.clone(tab, extData); + }, + + copyFromCache(permanentKey, tabData, options) { + TabStateInternal.copyFromCache(permanentKey, tabData, options); + }, +}); + +var TabStateInternal = { + /** + * Processes a data update sent by the content script. + */ + update(permanentKey, { data }) { + lazy.TabStateCache.update(permanentKey, data); + }, + + /** + * Collect data related to a single tab, synchronously. + * + * @param tab + * tabbrowser tab + * @param [extData] + * optional dictionary object, containing custom tab values. + * + * @returns {TabData} An object with the data for this tab. If the + * tab has not been invalidated since the last call to + * collect(aTab), the same object is returned. + */ + collect(tab, extData) { + return this._collectBaseTabData(tab, { extData }); + }, + + /** + * Collect data related to a single tab, including private data. + * Use with caution. + * + * @param tab + * tabbrowser tab + * @param [extData] + * optional dictionary object, containing custom tab values. + * + * @returns {object} An object with the data for this tab. This data is never + * cached, it will always be read from the tab and thus be + * up-to-date. + */ + clone(tab, extData) { + return this._collectBaseTabData(tab, { extData, includePrivateData: true }); + }, + + /** + * Collects basic tab data for a given tab. + * + * @param tab + * tabbrowser tab + * @param options (object) + * {extData: object} optional dictionary object, containing custom tab values + * {includePrivateData: true} to always include private data + * + * @returns {object} An object with the basic data for this tab. + */ + _collectBaseTabData(tab, options) { + let tabData = { entries: [], lastAccessed: tab.lastAccessed }; + let browser = tab.linkedBrowser; + + if (tab.pinned) { + tabData.pinned = true; + } + + tabData.hidden = tab.hidden; + + if (browser.audioMuted) { + tabData.muted = true; + tabData.muteReason = tab.muteReason; + } + + tabData.searchMode = tab.ownerGlobal.gURLBar.getSearchMode(browser, true); + + tabData.userContextId = tab.userContextId || 0; + + // Save tab attributes. + tabData.attributes = lazy.TabAttributes.get(tab); + + if (options.extData) { + tabData.extData = options.extData; + } + + // Copy data from the tab state cache only if the tab has fully finished + // restoring. We don't want to overwrite data contained in __SS_data. + this.copyFromCache(browser.permanentKey, tabData, options); + + // After copyFromCache() was called we check for properties that are kept + // in the cache only while the tab is pending or restoring. Once that + // happened those properties will be removed from the cache and will + // be read from the tab/browser every time we collect data. + + // Store the tab icon. + if (!("image" in tabData)) { + let tabbrowser = tab.ownerGlobal.gBrowser; + tabData.image = tabbrowser.getIcon(tab); + } + + // If there is a userTypedValue set, then either the user has typed something + // in the URL bar, or a new tab was opened with a URI to load. + // If so, we also track whether we were still in the process of loading something. + if (!("userTypedValue" in tabData) && browser.userTypedValue) { + tabData.userTypedValue = browser.userTypedValue; + // We always used to keep track of the loading state as an integer, where + // '0' indicated the user had typed since the last load (or no load was + // ongoing), and any positive value indicated we had started a load since + // the last time the user typed in the URL bar. Mimic this to keep the + // session store representation in sync, even though we now represent this + // more explicitly: + tabData.userTypedClear = browser.didStartLoadSinceLastUserTyping() + ? 1 + : 0; + } + + return tabData; + }, + + /** + * Copy data for the given |browser| from the cache to |tabData|. + * + * @param permanentKey (object) + * The browser belonging to the given |tabData| object. + * @param tabData (object) + * The tab data belonging to the given |tab|. + * @param options (object) + * {includePrivateData: true} to always include private data + */ + copyFromCache(permanentKey, tabData, options = {}) { + let data = lazy.TabStateCache.get(permanentKey); + if (!data) { + return; + } + + // The caller may explicitly request to omit privacy checks. + let includePrivateData = options && options.includePrivateData; + + for (let key of Object.keys(data)) { + let value = data[key]; + + // Filter sensitive data according to the current privacy level. + if (!includePrivateData) { + if (key === "storage") { + value = lazy.PrivacyFilter.filterSessionStorageData(value); + } else if (key === "formdata") { + value = lazy.PrivacyFilter.filterFormData(value); + } + } + + if (key === "history") { + // Make a shallow copy of the entries array. We (currently) don't update + // entries in place, so we don't have to worry about performing a deep + // copy. + tabData.entries = [...value.entries]; + + if (value.hasOwnProperty("index")) { + tabData.index = value.index; + } + + if (value.hasOwnProperty("requestedIndex")) { + tabData.requestedIndex = value.requestedIndex; + } + } else if (!value && (key == "scroll" || key == "formdata")) { + // [Bug 1554512] + + // If scroll or formdata null it indicates that the update to + // be performed is to remove them, and not copy a null + // value. Scroll will be null when the position is at the top + // of the document, formdata will be null when there is only + // default data. + delete tabData[key]; + } else { + tabData[key] = value; + } + } + }, +}; diff --git a/browser/components/sessionstore/TabStateCache.sys.mjs b/browser/components/sessionstore/TabStateCache.sys.mjs new file mode 100644 index 0000000000..81524c4d69 --- /dev/null +++ b/browser/components/sessionstore/TabStateCache.sys.mjs @@ -0,0 +1,171 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * A cache for tabs data. + * + * This cache implements a weak map from tabs (as XUL elements) + * to tab data (as objects). + * + * Note that we should never cache private data, as: + * - that data is used very seldom by SessionStore; + * - caching private data in addition to public data is memory consuming. + */ +export var TabStateCache = Object.freeze({ + /** + * Retrieves cached data for a given |tab| or associated |browser|. + * + * @param permanentKey (object) + * The tab or browser to retrieve cached data for. + * @return (object) + * The cached data stored for the given |tab| + * or associated |browser|. + */ + get(permanentKey) { + return TabStateCacheInternal.get(permanentKey); + }, + + /** + * Updates cached data for a given |tab| or associated |browser|. + * + * @param permanentKey (object) + * The tab or browser belonging to the given tab data. + * @param newData (object) + * The new data to be stored for the given |tab| + * or associated |browser|. + */ + update(permanentKey, newData) { + TabStateCacheInternal.update(permanentKey, newData); + }, +}); + +var TabStateCacheInternal = { + _data: new WeakMap(), + + /** + * Retrieves cached data for a given |tab| or associated |browser|. + * + * @param permanentKey (object) + * The tab or browser to retrieve cached data for. + * @return (object) + * The cached data stored for the given |tab| + * or associated |browser|. + */ + get(permanentKey) { + return this._data.get(permanentKey); + }, + + /** + * Helper function used by update (see below). For message size + * optimization sometimes we don't update the whole session storage + * only the values that have been changed. + * + * @param data (object) + * The cached data where we want to update the changes. + * @param change (object) + * The actual changed values per domain. + */ + updatePartialStorageChange(data, change) { + if (!data.storage) { + data.storage = {}; + } + + let storage = data.storage; + for (let domain of Object.keys(change)) { + if (!change[domain]) { + // We were sent null in place of the change object, which means + // we should delete session storage entirely for this domain. + delete storage[domain]; + } else { + for (let key of Object.keys(change[domain])) { + let value = change[domain][key]; + if (value === null) { + if (storage[domain] && storage[domain][key]) { + delete storage[domain][key]; + } + } else { + if (!storage[domain]) { + storage[domain] = {}; + } + storage[domain][key] = value; + } + } + } + } + }, + + /** + * Helper function used by update (see below). For message size + * optimization sometimes we don't update the whole browser history + * only the current index and the tail of the history from a certain + * index (specified by change.fromIdx) + * + * @param data (object) + * The cached data where we want to update the changes. + * @param change (object) + * Object containing the tail of the history array, and + * some additional metadata. + */ + updatePartialHistoryChange(data, change) { + const kLastIndex = Number.MAX_SAFE_INTEGER - 1; + + if (!data.history) { + data.history = { entries: [] }; + } + + let history = data.history; + let toIdx = history.entries.length; + if ("toIdx" in change) { + toIdx = Math.min(toIdx, change.toIdx + 1); + } + + for (let key of Object.keys(change)) { + if (key == "entries") { + if (change.fromIdx != kLastIndex) { + let start = change.fromIdx + 1; + history.entries.splice.apply( + history.entries, + [start, toIdx - start].concat(change.entries) + ); + } + } else if (key != "fromIdx" && key != "toIdx") { + history[key] = change[key]; + } + } + }, + + /** + * Updates cached data for a given |tab| or associated |browser|. + * + * @param permanentKey (object) + * The tab or browser belonging to the given tab data. + * @param newData (object) + * The new data to be stored for the given |tab| + * or associated |browser|. + */ + update(permanentKey, newData) { + let data = this._data.get(permanentKey) || {}; + + for (let key of Object.keys(newData)) { + if (key == "storagechange") { + this.updatePartialStorageChange(data, newData.storagechange); + continue; + } + + if (key == "historychange") { + this.updatePartialHistoryChange(data, newData.historychange); + continue; + } + + let value = newData[key]; + if (value === null) { + delete data[key]; + } else { + data[key] = value; + } + } + + this._data.set(permanentKey, data); + }, +}; diff --git a/browser/components/sessionstore/TabStateFlusher.sys.mjs b/browser/components/sessionstore/TabStateFlusher.sys.mjs new file mode 100644 index 0000000000..e391abc970 --- /dev/null +++ b/browser/components/sessionstore/TabStateFlusher.sys.mjs @@ -0,0 +1,234 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs", +}); + +/** + * A module that enables async flushes. Updates from frame scripts are + * throttled to be sent only once per second. If an action wants a tab's latest + * state without waiting for a second then it can request an async flush and + * wait until the frame scripts reported back. At this point the parent has the + * latest data and the action can continue. + */ +export var TabStateFlusher = Object.freeze({ + /** + * Requests an async flush for the given browser. Returns a promise that will + * resolve when we heard back from the content process and the parent has + * all the latest data. + */ + flush(browser) { + return TabStateFlusherInternal.flush(browser); + }, + + /** + * Requests an async flush for all browsers of a given window. Returns a Promise + * that will resolve when we've heard back from all browsers. + */ + flushWindow(window) { + return TabStateFlusherInternal.flushWindow(window); + }, + + /** + * Resolves the flush request with the given flush ID. + * + * @param browser () + * The browser for which the flush is being resolved. + * @param flushID (int) + * The ID of the flush that was sent to the browser. + * @param success (bool, optional) + * Whether or not the flush succeeded. + * @param message (string, optional) + * An error message that will be sent to the Console in the + * event that a flush failed. + */ + resolve(browser, flushID, success = true, message = "") { + TabStateFlusherInternal.resolve(browser, flushID, success, message); + }, + + /** + * Resolves all active flush requests for a given browser. This should be + * used when the content process crashed or the final update message was + * seen. In those cases we can't guarantee to ever hear back from the frame + * script so we just resolve all requests instead of discarding them. + * + * @param browser () + * The browser for which all flushes are being resolved. + * @param success (bool, optional) + * Whether or not the flushes succeeded. + * @param message (string, optional) + * An error message that will be sent to the Console in the + * event that the flushes failed. + */ + resolveAll(browser, success = true, message = "") { + TabStateFlusherInternal.resolveAll(browser, success, message); + }, +}); + +var TabStateFlusherInternal = { + // Stores the last request ID. + _lastRequestID: 0, + + // A map storing all active requests per browser. A request is a + // triple of a map containing all flush requests, a promise that + // resolve when a request for a browser is canceled, and the + // function to call to cancel a reqeust. + _requests: new WeakMap(), + + initEntry(entry) { + entry.perBrowserRequests = new Map(); + entry.cancelPromise = new Promise(resolve => { + entry.cancel = resolve; + }).then(result => { + TabStateFlusherInternal.initEntry(entry); + return result; + }); + + return entry; + }, + + /** + * Requests an async flush for the given browser. Returns a promise that will + * resolve when we heard back from the content process and the parent has + * all the latest data. + */ + flush(browser) { + let id = ++this._lastRequestID; + let nativePromise = Promise.resolve(); + if (browser && browser.frameLoader) { + /* + Request native listener to flush the tabState. + Resolves when flush is complete. + */ + nativePromise = browser.frameLoader.requestTabStateFlush(); + } + + if (!Services.appinfo.sessionHistoryInParent) { + /* + In the event that we have to trigger a process switch and thus change + browser remoteness, session store needs to register and track the new + browser window loaded and to have message manager listener registered + ** before ** TabStateFlusher send "SessionStore:flush" message. This fixes + the race where we send the message before the message listener is + registered for it. + */ + lazy.SessionStore.ensureInitialized(browser.ownerGlobal); + + let mm = browser.messageManager; + mm.sendAsyncMessage("SessionStore:flush", { + id, + epoch: lazy.SessionStore.getCurrentEpoch(browser), + }); + } + + // Retrieve active requests for given browser. + let permanentKey = browser.permanentKey; + let request = this._requests.get(permanentKey); + if (!request) { + // If we don't have any requests for this browser, create a new + // entry for browser. + request = this.initEntry({}); + this._requests.set(permanentKey, request); + } + + // Non-SHIP flushes resolve this after the "SessionStore:update" message. We + // don't use that message for SHIP, so it's fine to resolve the request + // immediately after the native promise resolves, since SessionStore will + // have processed all updates from this browser by that point. + let requestPromise = Promise.resolve(); + if (!Services.appinfo.sessionHistoryInParent) { + requestPromise = new Promise(resolve => { + // Store resolve() so that we can resolve the promise later. + request.perBrowserRequests.set(id, resolve); + }); + } + + return Promise.race([ + nativePromise.then(_ => requestPromise), + request.cancelPromise, + ]); + }, + + /** + * Requests an async flush for all non-lazy browsers of a given window. + * Returns a Promise that will resolve when we've heard back from all browsers. + */ + flushWindow(window) { + let promises = []; + for (let browser of window.gBrowser.browsers) { + if (window.gBrowser.getTabForBrowser(browser).linkedPanel) { + promises.push(this.flush(browser)); + } + } + return Promise.all(promises); + }, + + /** + * Resolves the flush request with the given flush ID. + * + * @param browser () + * The browser for which the flush is being resolved. + * @param flushID (int) + * The ID of the flush that was sent to the browser. + * @param success (bool, optional) + * Whether or not the flush succeeded. + * @param message (string, optional) + * An error message that will be sent to the Console in the + * event that a flush failed. + */ + resolve(browser, flushID, success = true, message = "") { + // Nothing to do if there are no pending flushes for the given browser. + if (!this._requests.has(browser.permanentKey)) { + return; + } + + // Retrieve active requests for given browser. + let { perBrowserRequests } = this._requests.get(browser.permanentKey); + if (!perBrowserRequests.has(flushID)) { + return; + } + + if (!success) { + console.error("Failed to flush browser: ", message); + } + + // Resolve the request with the given id. + let resolve = perBrowserRequests.get(flushID); + perBrowserRequests.delete(flushID); + resolve(success); + }, + + /** + * Resolves all active flush requests for a given browser. This should be + * used when the content process crashed or the final update message was + * seen. In those cases we can't guarantee to ever hear back from the frame + * script so we just resolve all requests instead of discarding them. + * + * @param browser () + * The browser for which all flushes are being resolved. + * @param success (bool, optional) + * Whether or not the flushes succeeded. + * @param message (string, optional) + * An error message that will be sent to the Console in the + * event that the flushes failed. + */ + resolveAll(browser, success = true, message = "") { + // Nothing to do if there are no pending flushes for the given browser. + if (!this._requests.has(browser.permanentKey)) { + return; + } + + // Retrieve the cancel function for a given browser. + let { cancel } = this._requests.get(browser.permanentKey); + + if (!success) { + console.error("Failed to flush browser: ", message); + } + + // Resolve all requests. + cancel(success); + }, +}; diff --git a/browser/components/sessionstore/content/aboutSessionRestore.js b/browser/components/sessionstore/content/aboutSessionRestore.js new file mode 100644 index 0000000000..4cbf8b7bdd --- /dev/null +++ b/browser/components/sessionstore/content/aboutSessionRestore.js @@ -0,0 +1,450 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); +ChromeUtils.defineESModuleGetters(this, { + SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs", +}); + +var gStateObject; +var gTreeData; +var gTreeInitialized = false; + +// Page initialization + +window.onload = function () { + let toggleTabs = document.getElementById("tabsToggle"); + if (toggleTabs) { + let tabList = document.getElementById("tabList"); + + let toggleHiddenTabs = () => { + toggleTabs.classList.toggle("tabs-hidden"); + tabList.hidden = toggleTabs.classList.contains("tabs-hidden"); + initTreeView(); + }; + toggleTabs.onclick = toggleHiddenTabs; + } + + // wire up click handlers for the radio buttons if they exist. + for (let radioId of ["radioRestoreAll", "radioRestoreChoose"]) { + let button = document.getElementById(radioId); + if (button) { + button.addEventListener("click", updateTabListVisibility); + } + } + + var tabListTree = document.getElementById("tabList"); + tabListTree.addEventListener("click", onListClick); + tabListTree.addEventListener("keydown", onListKeyDown); + + var errorCancelButton = document.getElementById("errorCancel"); + // aboutSessionRestore.js is included aboutSessionRestore.xhtml + // and aboutWelcomeBack.xhtml, but the latter does not have an + // errorCancel button. + if (errorCancelButton) { + errorCancelButton.addEventListener("command", startNewSession); + } + + var errorTryAgainButton = document.getElementById("errorTryAgain"); + errorTryAgainButton.addEventListener("command", restoreSession); + + // the crashed session state is kept inside a textbox so that SessionStore picks it up + // (for when the tab is closed or the session crashes right again) + var sessionData = document.getElementById("sessionData"); + if (!sessionData.value) { + errorTryAgainButton.disabled = true; + return; + } + + gStateObject = JSON.parse(sessionData.value); + + // make sure the data is tracked to be restored in case of a subsequent crash + var event = document.createEvent("UIEvents"); + event.initUIEvent("input", true, true, window, 0); + sessionData.dispatchEvent(event); + + initTreeView(); + + errorTryAgainButton.focus({ focusVisible: false }); +}; + +function isTreeViewVisible() { + return !document.getElementById("tabList").hidden; +} + +async function initTreeView() { + if (gTreeInitialized || !isTreeViewVisible()) { + return; + } + + var tabList = document.getElementById("tabList"); + let l10nIds = []; + for ( + let labelIndex = 0; + labelIndex < gStateObject.windows.length; + labelIndex++ + ) { + l10nIds.push({ + id: "restore-page-window-label", + args: { windowNumber: labelIndex + 1 }, + }); + } + let winLabels = await document.l10n.formatValues(l10nIds); + gTreeData = []; + gStateObject.windows.forEach(function (aWinData, aIx) { + var winState = { + label: winLabels[aIx], + open: true, + checked: true, + ix: aIx, + }; + winState.tabs = aWinData.tabs.map(function (aTabData) { + var entry = aTabData.entries[aTabData.index - 1] || { + url: "about:blank", + }; + var iconURL = aTabData.image || null; + // don't initiate a connection just to fetch a favicon (see bug 462863) + if (/^https?:/.test(iconURL)) { + iconURL = "moz-anno:favicon:" + iconURL; + } + return { + label: entry.title || entry.url, + checked: true, + src: iconURL, + parent: winState, + }; + }); + gTreeData.push(winState); + for (let tab of winState.tabs) { + gTreeData.push(tab); + } + }, this); + + tabList.view = treeView; + tabList.view.selection.select(0); + gTreeInitialized = true; +} + +// User actions +function updateTabListVisibility() { + document.getElementById("tabList").hidden = + !document.getElementById("radioRestoreChoose").checked; + initTreeView(); +} + +function restoreSession() { + Services.obs.notifyObservers(null, "sessionstore-initiating-manual-restore"); + document.getElementById("errorTryAgain").disabled = true; + + if (isTreeViewVisible()) { + if (!gTreeData.some(aItem => aItem.checked)) { + // This should only be possible when we have no "cancel" button, and thus + // the "Restore session" button always remains enabled. In that case and + // when nothing is selected, we just want a new session. + startNewSession(); + return; + } + + // remove all unselected tabs from the state before restoring it + var ix = gStateObject.windows.length - 1; + for (var t = gTreeData.length - 1; t >= 0; t--) { + if (treeView.isContainer(t)) { + if (gTreeData[t].checked === 0) { + // this window will be restored partially + gStateObject.windows[ix].tabs = gStateObject.windows[ix].tabs.filter( + (aTabData, aIx) => gTreeData[t].tabs[aIx].checked + ); + } else if (!gTreeData[t].checked) { + // this window won't be restored at all + gStateObject.windows.splice(ix, 1); + } + ix--; + } + } + } + var stateString = JSON.stringify(gStateObject); + + var top = getBrowserWindow(); + + // if there's only this page open, reuse the window for restoring the session + if (top.gBrowser.tabs.length == 1) { + SessionStore.setWindowState(top, stateString, true); + return; + } + + // restore the session into a new window and close the current tab + var newWindow = top.openDialog( + top.location, + "_blank", + "chrome,dialog=no,all" + ); + + Services.obs.addObserver(function observe(win, topic) { + if (win != newWindow) { + return; + } + + Services.obs.removeObserver(observe, topic); + SessionStore.setWindowState(newWindow, stateString, true); + + let tabbrowser = top.gBrowser; + let browser = window.docShell.chromeEventHandler; + let tab = tabbrowser.getTabForBrowser(browser); + tabbrowser.removeTab(tab); + }, "browser-delayed-startup-finished"); +} + +function startNewSession() { + if (Services.prefs.getIntPref("browser.startup.page") == 0) { + getBrowserWindow().gBrowser.loadURI(Services.io.newURI("about:blank"), { + triggeringPrincipal: Services.scriptSecurityManager.createNullPrincipal( + {} + ), + }); + } else { + getBrowserWindow().BrowserHome(); + } +} + +function onListClick(aEvent) { + // don't react to right-clicks + if (aEvent.button == 2) { + return; + } + + var cell = treeView.treeBox.getCellAt(aEvent.clientX, aEvent.clientY); + if (cell.col) { + // Restore this specific tab in the same window for middle/double/accel clicking + // on a tab's title. + let accelKey = + AppConstants.platform == "macosx" ? aEvent.metaKey : aEvent.ctrlKey; + if ( + (aEvent.button == 1 || + (aEvent.button == 0 && aEvent.detail == 2) || + accelKey) && + cell.col.id == "title" && + !treeView.isContainer(cell.row) + ) { + restoreSingleTab(cell.row, aEvent.shiftKey); + aEvent.stopPropagation(); + } else if (cell.col.id == "restore") { + toggleRowChecked(cell.row); + } + } +} + +function onListKeyDown(aEvent) { + switch (aEvent.keyCode) { + case KeyEvent.DOM_VK_SPACE: + toggleRowChecked(document.getElementById("tabList").currentIndex); + // Prevent page from scrolling on the space key. + aEvent.preventDefault(); + break; + case KeyEvent.DOM_VK_RETURN: + var ix = document.getElementById("tabList").currentIndex; + if (aEvent.ctrlKey && !treeView.isContainer(ix)) { + restoreSingleTab(ix, aEvent.shiftKey); + } + break; + } +} + +// Helper functions + +function getBrowserWindow() { + return window.browsingContext.topChromeWindow; +} + +function toggleRowChecked(aIx) { + function isChecked(aItem) { + return aItem.checked; + } + + var item = gTreeData[aIx]; + item.checked = !item.checked; + treeView.treeBox.invalidateRow(aIx); + + if (treeView.isContainer(aIx)) { + // (un)check all tabs of this window as well + for (let tab of item.tabs) { + tab.checked = item.checked; + treeView.treeBox.invalidateRow(gTreeData.indexOf(tab)); + } + } else { + // Update the window's checkmark as well (0 means "partially checked"). + let state = false; + if (item.parent.tabs.every(isChecked)) { + state = true; + } else if (item.parent.tabs.some(isChecked)) { + state = 0; + } + item.parent.checked = state; + + treeView.treeBox.invalidateRow(gTreeData.indexOf(item.parent)); + } + + // we only disable the button when there's no cancel button. + if (document.getElementById("errorCancel")) { + document.getElementById("errorTryAgain").disabled = + !gTreeData.some(isChecked); + } +} + +function restoreSingleTab(aIx, aShifted) { + var tabbrowser = getBrowserWindow().gBrowser; + var newTab = tabbrowser.addWebTab(); + var item = gTreeData[aIx]; + + var tabState = + gStateObject.windows[item.parent.ix].tabs[ + aIx - gTreeData.indexOf(item.parent) - 1 + ]; + // ensure tab would be visible on the tabstrip. + tabState.hidden = false; + SessionStore.setTabState(newTab, JSON.stringify(tabState)); + + // respect the preference as to whether to select the tab (the Shift key inverses) + if ( + Services.prefs.getBoolPref("browser.tabs.loadInBackground") != !aShifted + ) { + tabbrowser.selectedTab = newTab; + } +} + +// Tree controller + +var treeView = { + treeBox: null, + selection: null, + + get rowCount() { + return gTreeData.length; + }, + setTree(treeBox) { + this.treeBox = treeBox; + }, + getCellText(idx, column) { + return gTreeData[idx].label; + }, + isContainer(idx) { + return "open" in gTreeData[idx]; + }, + getCellValue(idx, column) { + return gTreeData[idx].checked; + }, + isContainerOpen(idx) { + return gTreeData[idx].open; + }, + isContainerEmpty(idx) { + return false; + }, + isSeparator(idx) { + return false; + }, + isSorted() { + return false; + }, + isEditable(idx, column) { + return false; + }, + canDrop(idx, orientation, dt) { + return false; + }, + getLevel(idx) { + return this.isContainer(idx) ? 0 : 1; + }, + + getParentIndex(idx) { + if (!this.isContainer(idx)) { + for (var t = idx - 1; t >= 0; t--) { + if (this.isContainer(t)) { + return t; + } + } + } + return -1; + }, + + hasNextSibling(idx, after) { + var thisLevel = this.getLevel(idx); + for (var t = after + 1; t < gTreeData.length; t++) { + if (this.getLevel(t) <= thisLevel) { + return this.getLevel(t) == thisLevel; + } + } + return false; + }, + + toggleOpenState(idx) { + if (!this.isContainer(idx)) { + return; + } + var item = gTreeData[idx]; + if (item.open) { + // remove this window's tab rows from the view + var thisLevel = this.getLevel(idx); + /* eslint-disable no-empty */ + for ( + var t = idx + 1; + t < gTreeData.length && this.getLevel(t) > thisLevel; + t++ + ) {} + /* eslint-disable no-empty */ + var deletecount = t - idx - 1; + gTreeData.splice(idx + 1, deletecount); + this.treeBox.rowCountChanged(idx + 1, -deletecount); + } else { + // add this window's tab rows to the view + var toinsert = gTreeData[idx].tabs; + for (var i = 0; i < toinsert.length; i++) { + gTreeData.splice(idx + i + 1, 0, toinsert[i]); + } + this.treeBox.rowCountChanged(idx + 1, toinsert.length); + } + item.open = !item.open; + this.treeBox.invalidateRow(idx); + }, + + getCellProperties(idx, column) { + if ( + column.id == "restore" && + this.isContainer(idx) && + gTreeData[idx].checked === 0 + ) { + return "partial"; + } + if (column.id == "title") { + return this.getImageSrc(idx, column) ? "icon" : "noicon"; + } + + return ""; + }, + + getRowProperties(idx) { + var winState = gTreeData[idx].parent || gTreeData[idx]; + if (winState.ix % 2 != 0) { + return "alternate"; + } + + return ""; + }, + + getImageSrc(idx, column) { + if (column.id == "title") { + return gTreeData[idx].src || null; + } + return null; + }, + + cycleHeader(column) {}, + cycleCell(idx, column) {}, + selectionChanged() {}, + getColumnProperties(column) { + return ""; + }, +}; diff --git a/browser/components/sessionstore/content/aboutSessionRestore.xhtml b/browser/components/sessionstore/content/aboutSessionRestore.xhtml new file mode 100644 index 0000000000..05538be5d9 --- /dev/null +++ b/browser/components/sessionstore/content/aboutSessionRestore.xhtml @@ -0,0 +1,69 @@ + + + + %htmlDTD; +]> + + + + + + + + + + + + + diff --git a/browser/components/sessionstore/test/browser_1284886_suspend_tab.html b/browser/components/sessionstore/test/browser_1284886_suspend_tab.html new file mode 100644 index 0000000000..ec3edbffdc --- /dev/null +++ b/browser/components/sessionstore/test/browser_1284886_suspend_tab.html @@ -0,0 +1,12 @@ + + + + + +TEST PAGE + + diff --git a/browser/components/sessionstore/test/browser_1284886_suspend_tab.js b/browser/components/sessionstore/test/browser_1284886_suspend_tab.js new file mode 100644 index 0000000000..3c6a2c1f2c --- /dev/null +++ b/browser/components/sessionstore/test/browser_1284886_suspend_tab.js @@ -0,0 +1,95 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_task(async function test() { + await SpecialPowers.pushPrefEnv({ + set: [["dom.require_user_interaction_for_beforeunload", false]], + }); + + let url = "about:robots"; + let tab0 = gBrowser.tabs[0]; + let tab1 = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + + const staleAttributes = [ + "activemedia-blocked", + "busy", + "pendingicon", + "progress", + "soundplaying", + ]; + for (let attr of staleAttributes) { + tab0.toggleAttribute(attr, true); + } + gBrowser.discardBrowser(tab0); + ok(!tab0.linkedPanel, "tab0 is suspended"); + for (let attr of staleAttributes) { + ok( + !tab0.hasAttribute(attr), + `discarding browser removes "${attr}" tab attribute` + ); + } + + await BrowserTestUtils.switchTab(gBrowser, tab0); + ok(tab0.linkedPanel, "selecting tab unsuspends it"); + + // Test that active tab is not able to be suspended. + gBrowser.discardBrowser(tab0); + ok(tab0.linkedPanel, "active tab is not able to be suspended"); + + // Test that tab that is closing is not able to be suspended. + gBrowser._beginRemoveTab(tab1); + gBrowser.discardBrowser(tab1); + + ok(tab1.linkedPanel, "cannot suspend a tab that is closing"); + + gBrowser._endRemoveTab(tab1); + + // Open tab containing a page which has a beforeunload handler which shows a prompt. + url = + "http://example.com/browser/browser/components/sessionstore/test/browser_1284886_suspend_tab.html"; + tab1 = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await BrowserTestUtils.switchTab(gBrowser, tab0); + + // Test that tab with beforeunload handler which would show a prompt cannot be suspended. + gBrowser.discardBrowser(tab1); + ok( + tab1.linkedPanel, + "cannot suspend a tab with beforeunload handler which would show a prompt" + ); + + // Test that tab with beforeunload handler which would show a prompt will be suspended if forced. + gBrowser.discardBrowser(tab1, true); + ok( + !tab1.linkedPanel, + "force suspending a tab with beforeunload handler which would show a prompt" + ); + + BrowserTestUtils.removeTab(tab1); + + // Open tab containing a page which has a beforeunload handler which does not show a prompt. + url = + "http://example.com/browser/browser/components/sessionstore/test/browser_1284886_suspend_tab_2.html"; + tab1 = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await BrowserTestUtils.switchTab(gBrowser, tab0); + + // Test that tab with beforeunload handler which would not show a prompt can be suspended. + gBrowser.discardBrowser(tab1); + ok( + !tab1.linkedPanel, + "can suspend a tab with beforeunload handler which would not show a prompt" + ); + + BrowserTestUtils.removeTab(tab1); + + // Test that non-remote tab is not able to be suspended. + url = "about:robots"; + tab1 = BrowserTestUtils.addTab(gBrowser, url, { forceNotRemote: true }); + await promiseBrowserLoaded(tab1.linkedBrowser, true, url); + await BrowserTestUtils.switchTab(gBrowser, tab1); + await BrowserTestUtils.switchTab(gBrowser, tab0); + + gBrowser.discardBrowser(tab1); + ok(tab1.linkedPanel, "cannot suspend a remote tab"); + + BrowserTestUtils.removeTab(tab1); +}); diff --git a/browser/components/sessionstore/test/browser_1284886_suspend_tab_2.html b/browser/components/sessionstore/test/browser_1284886_suspend_tab_2.html new file mode 100644 index 0000000000..5c42913635 --- /dev/null +++ b/browser/components/sessionstore/test/browser_1284886_suspend_tab_2.html @@ -0,0 +1,11 @@ + + + + + +TEST PAGE + + diff --git a/browser/components/sessionstore/test/browser_1446343-windowsize.js b/browser/components/sessionstore/test/browser_1446343-windowsize.js new file mode 100644 index 0000000000..97f664a460 --- /dev/null +++ b/browser/components/sessionstore/test/browser_1446343-windowsize.js @@ -0,0 +1,39 @@ +add_task(async function test() { + const win = await BrowserTestUtils.openNewBrowserWindow(); + + async function changeSizeMode(mode) { + let promise = BrowserTestUtils.waitForEvent(win, "sizemodechange"); + win[mode](); + await promise; + } + if (win.windowState != win.STATE_NORMAL) { + await changeSizeMode("restore"); + } + + const { outerWidth, outerHeight, screenX, screenY } = win; + function checkCurrentState(sizemode) { + let state = ss.getWindowState(win); + let winState = state.windows[0]; + let msgSuffix = ` should match on ${sizemode} mode`; + is(winState.width, outerWidth, "width" + msgSuffix); + is(winState.height, outerHeight, "height" + msgSuffix); + // The position attributes seem to be affected on macOS when the + // window gets maximized, so skip checking them for now. + if (AppConstants.platform != "macosx" || sizemode == "normal") { + is(winState.screenX, screenX, "screenX" + msgSuffix); + is(winState.screenY, screenY, "screenY" + msgSuffix); + } + is(winState.sizemode, sizemode, "sizemode should match"); + } + + checkCurrentState("normal"); + + await changeSizeMode("maximize"); + checkCurrentState("maximized"); + + await changeSizeMode("minimize"); + checkCurrentState("minimized"); + + // Clean up. + await BrowserTestUtils.closeWindow(win); +}); diff --git a/browser/components/sessionstore/test/browser_248970_b_perwindowpb.js b/browser/components/sessionstore/test/browser_248970_b_perwindowpb.js new file mode 100644 index 0000000000..5dfbc9bc9a --- /dev/null +++ b/browser/components/sessionstore/test/browser_248970_b_perwindowpb.js @@ -0,0 +1,198 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 test() { + /** Test (B) for Bug 248970 **/ + waitForExplicitFinish(); + + let windowsToClose = []; + let file = Services.dirsvc.get("TmpD", Ci.nsIFile); + let filePath = file.path; + let fieldList = { + "//input[@name='input']": Date.now().toString(16), + "//input[@name='spaced 1']": Math.random().toString(), + "//input[3]": "three", + "//input[@type='checkbox']": true, + "//input[@name='uncheck']": false, + "//input[@type='radio'][1]": false, + "//input[@type='radio'][2]": true, + "//input[@type='radio'][3]": false, + "//select": 2, + "//select[@multiple]": [1, 3], + "//textarea[1]": "", + "//textarea[2]": "Some text... " + Math.random(), + "//textarea[3]": "Some more text\n" + new Date(), + "//input[@type='file']": filePath, + }; + + registerCleanupFunction(async function () { + for (let win of windowsToClose) { + await BrowserTestUtils.closeWindow(win); + } + }); + + function checkNoThrow(aLambda) { + try { + return aLambda() || true; + } catch (ex) {} + return false; + } + + function getElementByXPath(aTab, aQuery) { + let doc = aTab.linkedBrowser.contentDocument; + let xptype = doc.defaultView.XPathResult.FIRST_ORDERED_NODE_TYPE; + return doc.evaluate(aQuery, doc, null, xptype, null).singleNodeValue; + } + + function setFormValue(aTab, aQuery, aValue) { + let node = getElementByXPath(aTab, aQuery); + if (typeof aValue == "string") { + node.value = aValue; + } else if (typeof aValue == "boolean") { + node.checked = aValue; + } else if (typeof aValue == "number") { + node.selectedIndex = aValue; + } else { + Array.prototype.forEach.call( + node.options, + (aOpt, aIx) => (aOpt.selected = aValue.indexOf(aIx) > -1) + ); + } + } + + function compareFormValue(aTab, aQuery, aValue) { + let node = getElementByXPath(aTab, aQuery); + if (!node) { + return false; + } + if (ChromeUtils.getClassName(node) === "HTMLInputElement") { + return ( + aValue == + (node.type == "checkbox" || node.type == "radio" + ? node.checked + : node.value) + ); + } + if (ChromeUtils.getClassName(node) === "HTMLTextAreaElement") { + return aValue == node.value; + } + if (!node.multiple) { + return aValue == node.selectedIndex; + } + return Array.prototype.every.call( + node.options, + (aOpt, aIx) => aValue.indexOf(aIx) > -1 == aOpt.selected + ); + } + + /** + * Test (B) : Session data restoration between windows + */ + + let rootDir = getRootDirectory(gTestPath); + const testURL = rootDir + "browser_248970_b_sample.html"; + const testURL2 = + "http://mochi.test:8888/browser/" + + "browser/components/sessionstore/test/browser_248970_b_sample.html"; + + whenNewWindowLoaded({ private: false }, function (aWin) { + windowsToClose.push(aWin); + + // get closed tab count + let count = ss.getClosedTabCountForWindow(aWin); + let max_tabs_undo = Services.prefs.getIntPref( + "browser.sessionstore.max_tabs_undo" + ); + ok( + 0 <= count && count <= max_tabs_undo, + "getClosedTabCountForWindow should return zero or at most max_tabs_undo" + ); + + // setup a state for tab (A) so we can check later that is restored + let value = "Value " + Math.random(); + let state = { entries: [{ url: testURL }], extData: { key: value } }; + + // public session, add new tab: (A) + let tab_A = BrowserTestUtils.addTab(aWin.gBrowser, testURL); + ss.setTabState(tab_A, JSON.stringify(state)); + promiseBrowserLoaded(tab_A.linkedBrowser).then(() => { + // make sure that the next closed tab will increase getClosedTabCountForWindow + Services.prefs.setIntPref( + "browser.sessionstore.max_tabs_undo", + max_tabs_undo + 1 + ); + + // populate tab_A with form data + for (let i in fieldList) { + setFormValue(tab_A, i, fieldList[i]); + } + + // public session, close tab: (A) + aWin.gBrowser.removeTab(tab_A); + + // verify that closedTabCount increased + ok( + ss.getClosedTabCountForWindow(aWin) > count, + "getClosedTabCountForWindow has increased after closing a tab" + ); + + // verify tab: (A), in undo list + let tab_A_restored = checkNoThrow(() => ss.undoCloseTab(aWin, 0)); + ok(tab_A_restored, "a tab is in undo list"); + promiseTabRestored(tab_A_restored).then(() => { + is( + testURL, + tab_A_restored.linkedBrowser.currentURI.spec, + "it's the same tab that we expect" + ); + aWin.gBrowser.removeTab(tab_A_restored); + + whenNewWindowLoaded({ private: true }, function (win) { + windowsToClose.push(win); + + // setup a state for tab (B) so we can check that its duplicated + // properly + let key1 = "key1"; + let value1 = "Value " + Math.random(); + let state1 = { + entries: [{ url: testURL2 }], + extData: { key1: value1 }, + }; + + let tab_B = BrowserTestUtils.addTab(win.gBrowser, testURL2); + promiseTabState(tab_B, state1).then(() => { + // populate tab: (B) with different form data + for (let item in fieldList) { + setFormValue(tab_B, item, fieldList[item]); + } + + // duplicate tab: (B) + let tab_C = win.gBrowser.duplicateTab(tab_B); + promiseTabRestored(tab_C).then(() => { + // verify the correctness of the duplicated tab + is( + ss.getCustomTabValue(tab_C, key1), + value1, + "tab successfully duplicated - correct state" + ); + + for (let item in fieldList) { + ok( + compareFormValue(tab_C, item, fieldList[item]), + 'The value for "' + item + '" was correctly duplicated' + ); + } + + // private browsing session, close tab: (C) and (B) + win.gBrowser.removeTab(tab_C); + win.gBrowser.removeTab(tab_B); + + finish(); + }); + }); + }); + }); + }); + }); +} diff --git a/browser/components/sessionstore/test/browser_248970_b_sample.html b/browser/components/sessionstore/test/browser_248970_b_sample.html new file mode 100644 index 0000000000..76c3ae1aa0 --- /dev/null +++ b/browser/components/sessionstore/test/browser_248970_b_sample.html @@ -0,0 +1,37 @@ + + +Test for bug 248970 + +

Text Fields

+ + + + +

Checkboxes and Radio buttons

+ Check 1 + Check 2 +

+ Radio 1 + Radio 2 + Radio 3 + +

Selects

+ + + +

Text Areas

+ + + + +

File Selector

+ diff --git a/browser/components/sessionstore/test/browser_339445.js b/browser/components/sessionstore/test/browser_339445.js new file mode 100644 index 0000000000..e7c7ffa5cb --- /dev/null +++ b/browser/components/sessionstore/test/browser_339445.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/. */ + +add_task(async function test() { + /** Test for Bug 339445 **/ + + let testURL = + "http://mochi.test:8888/browser/" + + "browser/components/sessionstore/test/browser_339445_sample.html"; + + let tab = BrowserTestUtils.addTab(gBrowser, testURL); + await promiseBrowserLoaded(tab.linkedBrowser, true, testURL); + + await SpecialPowers.spawn(tab.linkedBrowser, [], function () { + let doc = content.document; + is( + doc.getElementById("storageTestItem").textContent, + "PENDING", + "sessionStorage value has been set" + ); + }); + + let tab2 = gBrowser.duplicateTab(tab); + await promiseTabRestored(tab2); + + await ContentTask.spawn(tab2.linkedBrowser, null, function () { + let doc2 = content.document; + is( + doc2.getElementById("storageTestItem").textContent, + "SUCCESS", + "sessionStorage value has been duplicated" + ); + }); + + // clean up + BrowserTestUtils.removeTab(tab2); + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/sessionstore/test/browser_339445_sample.html b/browser/components/sessionstore/test/browser_339445_sample.html new file mode 100644 index 0000000000..ff5b4acd9f --- /dev/null +++ b/browser/components/sessionstore/test/browser_339445_sample.html @@ -0,0 +1,18 @@ + + +Test for bug 339445 + +storageTestItem = FAIL + + + + diff --git a/browser/components/sessionstore/test/browser_345898.js b/browser/components/sessionstore/test/browser_345898.js new file mode 100644 index 0000000000..7e08702222 --- /dev/null +++ b/browser/components/sessionstore/test/browser_345898.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/. */ + +function test() { + /** Test for Bug 345898 **/ + + // all of the following calls with illegal arguments should throw NS_ERROR_ILLEGAL_VALUE + Assert.throws( + () => ss.getWindowState({}), + /NS_ERROR_ILLEGAL_VALUE/, + "Invalid window for getWindowState throws" + ); + Assert.throws( + () => ss.setWindowState({}, "", false), + /NS_ERROR_ILLEGAL_VALUE/, + "Invalid window for setWindowState throws" + ); + Assert.throws( + () => ss.getTabState({}), + /NS_ERROR_ILLEGAL_VALUE/, + "Invalid tab for getTabState throws" + ); + Assert.throws( + () => ss.setTabState({}, "{}"), + /NS_ERROR_ILLEGAL_VALUE/, + "Invalid tab state for setTabState throws" + ); + Assert.throws( + () => ss.setTabState({}, JSON.stringify({ entries: [] })), + /NS_ERROR_ILLEGAL_VALUE/, + "Invalid tab for setTabState throws" + ); + Assert.throws( + () => ss.duplicateTab({}, {}), + /NS_ERROR_ILLEGAL_VALUE/, + "Invalid tab for duplicateTab throws" + ); + Assert.throws( + () => ss.duplicateTab({}, gBrowser.selectedTab), + /NS_ERROR_ILLEGAL_VALUE/, + "Invalid window for duplicateTab throws" + ); + Assert.throws( + () => ss.getClosedTabDataForWindow({}), + /NS_ERROR_ILLEGAL_VALUE/, + "Invalid window for getClosedTabData throws" + ); + Assert.throws( + () => ss.undoCloseTab({}, 0), + /NS_ERROR_ILLEGAL_VALUE/, + "Invalid window for undoCloseTab throws" + ); + Assert.throws( + () => ss.undoCloseTab(window, -1), + /NS_ERROR_ILLEGAL_VALUE/, + "Invalid index for undoCloseTab throws" + ); + Assert.throws( + () => ss.getCustomWindowValue({}, ""), + /NS_ERROR_ILLEGAL_VALUE/, + "Invalid window for getCustomWindowValue throws" + ); + Assert.throws( + () => ss.setCustomWindowValue({}, "", ""), + /NS_ERROR_ILLEGAL_VALUE/, + "Invalid window for setCustomWindowValue throws" + ); +} diff --git a/browser/components/sessionstore/test/browser_350525.js b/browser/components/sessionstore/test/browser_350525.js new file mode 100644 index 0000000000..a954cbba3a --- /dev/null +++ b/browser/components/sessionstore/test/browser_350525.js @@ -0,0 +1,135 @@ +"use strict"; + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["dom.ipc.processCount", 1]], + }); +}); + +add_task(async function () { + /** Test for Bug 350525 **/ + + function test(aLambda) { + try { + return aLambda() || true; + } catch (ex) {} + return false; + } + + /** + * setCustomWindowValue, et al. + */ + let key = "Unique name: " + Date.now(); + let value = "Unique value: " + Math.random(); + + // test adding + ok( + test(() => ss.setCustomWindowValue(window, key, value)), + "set a window value" + ); + + // test retrieving + is( + ss.getCustomWindowValue(window, key), + value, + "stored window value matches original" + ); + + // test deleting + ok( + test(() => ss.deleteCustomWindowValue(window, key)), + "delete the window value" + ); + + // value should not exist post-delete + is(ss.getCustomWindowValue(window, key), "", "window value was deleted"); + + // test deleting a non-existent value + ok( + test(() => ss.deleteCustomWindowValue(window, key)), + "delete non-existent window value" + ); + + /** + * setCustomTabValue, et al. + */ + key = "Unique name: " + Math.random(); + value = "Unique value: " + Date.now(); + let tab = BrowserTestUtils.addTab(gBrowser); + tab.linkedBrowser.stop(); + + // test adding + ok( + test(() => ss.setCustomTabValue(tab, key, value)), + "store a tab value" + ); + + // test retrieving + is(ss.getCustomTabValue(tab, key), value, "stored tab value match original"); + + // test deleting + ok( + test(() => ss.deleteCustomTabValue(tab, key)), + "delete the tab value" + ); + + // value should not exist post-delete + is(ss.getCustomTabValue(tab, key), "", "tab value was deleted"); + + // test deleting a non-existent value + ok( + test(() => ss.deleteCustomTabValue(tab, key)), + "delete non-existent tab value" + ); + + // clean up + await promiseRemoveTabAndSessionState(tab); + + /** + * getClosedTabCountForWindow, undoCloseTab + */ + + // get closed tab count + let count = ss.getClosedTabCountForWindow(window); + let max_tabs_undo = Services.prefs.getIntPref( + "browser.sessionstore.max_tabs_undo" + ); + ok( + 0 <= count && count <= max_tabs_undo, + "getClosedTabCountForWindow returns zero or at most max_tabs_undo" + ); + + // create a new tab + let testURL = "about:mozilla"; + tab = BrowserTestUtils.addTab(gBrowser, testURL); + await promiseBrowserLoaded(tab.linkedBrowser); + + // make sure that the next closed tab will increase getClosedTabCountForWindow + Services.prefs.setIntPref( + "browser.sessionstore.max_tabs_undo", + max_tabs_undo + 1 + ); + registerCleanupFunction(() => + Services.prefs.clearUserPref("browser.sessionstore.max_tabs_undo") + ); + + // remove tab + await promiseRemoveTabAndSessionState(tab); + + // getClosedTabCountForWindow + let newcount = ss.getClosedTabCountForWindow(window); + ok( + newcount > count, + "after closing a tab, getClosedTabCountForWindow has been incremented" + ); + + // undoCloseTab + tab = test(() => ss.undoCloseTab(window, 0)); + ok(tab, "undoCloseTab doesn't throw"); + + await promiseTabRestored(tab); + is(tab.linkedBrowser.currentURI.spec, testURL, "correct tab was reopened"); + + // clean up + gBrowser.removeTab(tab); +}); diff --git a/browser/components/sessionstore/test/browser_354894_perwindowpb.js b/browser/components/sessionstore/test/browser_354894_perwindowpb.js new file mode 100644 index 0000000000..90368536dc --- /dev/null +++ b/browser/components/sessionstore/test/browser_354894_perwindowpb.js @@ -0,0 +1,489 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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"; + +/** + * Checks that restoring the last browser window in session is actually + * working. + * + * @see https://bugzilla.mozilla.org/show_bug.cgi?id=354894 + * @note It is implicitly tested that restoring the last window works when + * non-browser windows are around. The "Run Tests" window as well as the main + * browser window (wherein the test code gets executed) won't be considered + * browser windows. To achiveve this said main browser window has its windowtype + * attribute modified so that it's not considered a browser window any longer. + * This is crucial, because otherwise there would be two browser windows around, + * said main test window and the one opened by the tests, and hence the new + * logic wouldn't be executed at all. + * @note Mac only tests the new notifications, as restoring the last window is + * not enabled on that platform (platform shim; the application is kept running + * although there are no windows left) + * @note There is a difference when closing a browser window with + * BrowserTryToCloseWindow() as opposed to close(). The former will make + * nsSessionStore restore a window next time it gets a chance and will post + * notifications. The latter won't. + */ + +// The rejection "BrowserWindowTracker.getTopWindow(...) is null" is left +// unhandled in some cases. This bug should be fixed, but for the moment this +// file allows a class of rejections. +// +// NOTE: Allowing a whole class of rejections should be avoided. Normally you +// should use "expectUncaughtRejection" to flag individual failures. +const { PromiseTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PromiseTestUtils.sys.mjs" +); +PromiseTestUtils.allowMatchingRejectionsGlobally(/getTopWindow/); + +// Some urls that might be opened in tabs and/or popups +// Do not use about:blank: +// That one is reserved for special purposes in the tests +const TEST_URLS = ["about:mozilla", "about:buildconfig"]; + +// Number of -request notifications to except +// remember to adjust when adding new tests +const NOTIFICATIONS_EXPECTED = 6; + +// Window features of popup windows +const POPUP_FEATURES = "toolbar=no,resizable=no,status=no"; + +// Window features of browser windows +const CHROME_FEATURES = "chrome,all,dialog=no"; + +const IS_MAC = navigator.platform.match(/Mac/); + +/** + * Returns an Object with two properties: + * open (int): + * A count of how many non-closed navigator:browser windows there are. + * winstates (int): + * A count of how many windows there are in the SessionStore state. + */ +function getBrowserWindowsCount() { + let open = 0; + for (let win of Services.wm.getEnumerator("navigator:browser")) { + if (!win.closed) { + ++open; + } + } + + let winstates = JSON.parse(ss.getBrowserState()).windows.length; + + return { open, winstates }; +} + +add_setup(async function () { + // Make sure we've only got one browser window to start with + let { open, winstates } = getBrowserWindowsCount(); + is(open, 1, "Should only be one open window"); + is(winstates, 1, "Should only be one window state in SessionStore"); + + // This test takes some time to run, and it could timeout randomly. + // So we require a longer timeout. See bug 528219. + requestLongerTimeout(3); + + // Make the main test window not count as a browser window any longer + let oldWinType = document.documentElement.getAttribute("windowtype"); + document.documentElement.setAttribute("windowtype", "navigator:testrunner"); + + registerCleanupFunction(() => { + document.documentElement.setAttribute("windowtype", oldWinType); + }); +}); + +/** + * Sets up one of our tests by setting the right preferences, and + * then opening up a browser window preloaded with some tabs. + * + * @param options (Object) + * An object that can contain the following properties: + * + * private: + * Whether or not the opened window should be private. + * + * denyFirst: + * Whether or not the first window that attempts to close + * via closeWindowForRestoration should be denied. + * + * @param testFunction (Function*) + * A generator function that yields Promises to be run + * once the test has been set up. + * + * @returns Promise + * Resolves once the test has been cleaned up. + */ +let setupTest = async function (options, testFunction) { + await pushPrefs( + ["browser.startup.page", 3], + ["browser.tabs.warnOnClose", false] + ); + // SessionStartup caches pref values, but as this test tries to simulate a + // startup scenario, we'll reset them here. + SessionStartup.resetForTest(); + + // Observe these, and also use to count the number of hits + let observing = { + "browser-lastwindow-close-requested": 0, + "browser-lastwindow-close-granted": 0, + }; + + /** + * Helper: Will observe and handle the notifications for us + */ + let hitCount = 0; + function observer(aCancel, aTopic, aData) { + // count so that we later may compare + observing[aTopic]++; + + // handle some tests + if (options.denyFirst && ++hitCount == 1) { + aCancel.QueryInterface(Ci.nsISupportsPRBool).data = true; + } + } + + for (let o in observing) { + Services.obs.addObserver(observer, o); + } + + let newWin = await promiseNewWindowLoaded({ + private: options.private || false, + }); + + await injectTestTabs(newWin); + + await testFunction(newWin, observing); + + let count = getBrowserWindowsCount(); + is(count.open, 0, "Got right number of open windows"); + is(count.winstates, 1, "Got right number of stored window states"); + + for (let o in observing) { + Services.obs.removeObserver(observer, o); + } + + await popPrefs(); + // Act like nothing ever happened. + SessionStartup.resetForTest(); +}; + +/** + * Loads a TEST_URLS into a browser window. + * + * @param win (Window) + * The browser window to load the tabs in + */ +function injectTestTabs(win) { + let promises = TEST_URLS.map(url => + BrowserTestUtils.addTab(win.gBrowser, url) + ).map(tab => BrowserTestUtils.browserLoaded(tab.linkedBrowser)); + return Promise.all(promises); +} + +/** + * Attempts to close a window via BrowserTryToCloseWindow so that + * we get the browser-lastwindow-close-requested and + * browser-lastwindow-close-granted observer notifications. + * + * @param win (Window) + * The window to try to close + * @returns Promise + * Resolves to true if the window closed, or false if the window + * was denied the ability to close. + */ +function closeWindowForRestoration(win) { + return new Promise(resolve => { + let closePromise = BrowserTestUtils.windowClosed(win); + win.BrowserTryToCloseWindow(); + if (!win.closed) { + resolve(false); + return; + } + + closePromise.then(() => { + resolve(true); + }); + }); +} + +/** + * Normal in-session restore + * + * @note: Non-Mac only + * + * Should do the following: + * 1. Open a new browser window + * 2. Add some tabs + * 3. Close that window + * 4. Opening another window + * 5. Checks that state is restored + */ +add_task(async function test_open_close_normal() { + if (IS_MAC) { + return; + } + + await setupTest({ denyFirst: true }, async function (newWin, obs) { + let closed = await closeWindowForRestoration(newWin); + ok(!closed, "First close request should have been denied"); + + closed = await closeWindowForRestoration(newWin); + ok(closed, "Second close request should be accepted"); + + newWin = await promiseNewWindowLoaded(); + is( + newWin.gBrowser.browsers.length, + TEST_URLS.length + 2, + "Restored window in-session with otherpopup windows around" + ); + + // Note that this will not result in the the browser-lastwindow-close + // notifications firing for this other newWin. + await BrowserTestUtils.closeWindow(newWin); + + // setupTest gave us a window which was denied for closing once, and then + // closed. + is( + obs["browser-lastwindow-close-requested"], + 2, + "Got expected browser-lastwindow-close-requested notifications" + ); + is( + obs["browser-lastwindow-close-granted"], + 1, + "Got expected browser-lastwindow-close-granted notifications" + ); + }); +}); + +/** + * PrivateBrowsing in-session restore + * + * @note: Non-Mac only + * + * Should do the following: + * 1. Open a new browser window A + * 2. Add some tabs + * 3. Close the window A as the last window + * 4. Open a private browsing window B + * 5. Make sure that B didn't restore the tabs from A + * 6. Close private browsing window B + * 7. Open a new window C + * 8. Make sure that new window C has restored tabs from A + */ +add_task(async function test_open_close_private_browsing() { + if (IS_MAC) { + return; + } + + await setupTest({}, async function (newWin, obs) { + let closed = await closeWindowForRestoration(newWin); + ok(closed, "Should be able to close the window"); + + newWin = await promiseNewWindowLoaded({ private: true }); + is( + newWin.gBrowser.browsers.length, + 1, + "Did not restore in private browsing mode" + ); + + closed = await closeWindowForRestoration(newWin); + ok(closed, "Should be able to close the window"); + + newWin = await promiseNewWindowLoaded(); + is( + newWin.gBrowser.browsers.length, + TEST_URLS.length + 2, + "Restored tabs in a new non-private window" + ); + + // Note that this will not result in the the browser-lastwindow-close + // notifications firing for this other newWin. + await BrowserTestUtils.closeWindow(newWin); + + // We closed two windows with closeWindowForRestoration, and both + // should have been successful. + is( + obs["browser-lastwindow-close-requested"], + 2, + "Got expected browser-lastwindow-close-requested notifications" + ); + is( + obs["browser-lastwindow-close-granted"], + 2, + "Got expected browser-lastwindow-close-granted notifications" + ); + }); +}); + +/** + * Open some popup window to check it isn't restored. Instead nothing at all + * should be restored + * + * @note: Non-Mac only + * + * Should do the following: + * 1. Open a popup + * 2. Add another tab to the popup (so that it gets stored) and close it again + * 3. Open a window + * 4. Check that nothing at all is restored + * 5. Open two browser windows and close them again + * 6. undoCloseWindow() one + * 7. Open another browser window + * 8. Check that nothing at all is restored + */ +add_task(async function test_open_close_only_popup() { + if (IS_MAC) { + return; + } + + await setupTest({}, async function (newWin, obs) { + // We actually don't care about the initial window in this test. + await BrowserTestUtils.closeWindow(newWin); + + // This will cause nsSessionStore to restore a window the next time it + // gets a chance. + let popupPromise = BrowserTestUtils.waitForNewWindow(); + openDialog(location, "popup", POPUP_FEATURES, TEST_URLS[1]); + let popup = await popupPromise; + + is( + popup.gBrowser.browsers.length, + 1, + "Did not restore the popup window (1)" + ); + + let closed = await closeWindowForRestoration(popup); + ok(closed, "Should be able to close the window"); + + popupPromise = BrowserTestUtils.waitForNewWindow(); + openDialog(location, "popup", POPUP_FEATURES, TEST_URLS[1]); + popup = await popupPromise; + + BrowserTestUtils.addTab(popup.gBrowser, TEST_URLS[0]); + is( + popup.gBrowser.browsers.length, + 2, + "Did not restore to the popup window (2)" + ); + + await BrowserTestUtils.closeWindow(popup); + + newWin = await promiseNewWindowLoaded(); + isnot( + newWin.gBrowser.browsers.length, + 2, + "Did not restore the popup window" + ); + is( + TEST_URLS.indexOf(newWin.gBrowser.browsers[0].currentURI.spec), + -1, + "Did not restore the popup window (2)" + ); + await BrowserTestUtils.closeWindow(newWin); + + // We closed one popup window with closeWindowForRestoration, and popup + // windows should never fire the browser-lastwindow notifications. + is( + obs["browser-lastwindow-close-requested"], + 0, + "Got expected browser-lastwindow-close-requested notifications" + ); + is( + obs["browser-lastwindow-close-granted"], + 0, + "Got expected browser-lastwindow-close-granted notifications" + ); + }); +}); + +/** + * Open some windows and do undoCloseWindow. This should prevent any + * restoring later in the test + * + * @note: Non-Mac only + * + * Should do the following: + * 1. Open two browser windows and close them again + * 2. undoCloseWindow() one + * 3. Open another browser window + * 4. Make sure nothing at all is restored + */ +add_task(async function test_open_close_restore_from_popup() { + if (IS_MAC) { + return; + } + + await setupTest({}, async function (newWin, obs) { + let newWin2 = await promiseNewWindowLoaded(); + await injectTestTabs(newWin2); + + let closed = await closeWindowForRestoration(newWin); + ok(closed, "Should be able to close the window"); + closed = await closeWindowForRestoration(newWin2); + ok(closed, "Should be able to close the window"); + + let counts = getBrowserWindowsCount(); + is(counts.open, 0, "Got right number of open windows"); + is(counts.winstates, 1, "Got right number of window states"); + + newWin = undoCloseWindow(0); + await BrowserTestUtils.waitForEvent(newWin, "load"); + + // Make sure we wait until this window is restored. + await BrowserTestUtils.waitForEvent( + newWin.gBrowser.tabContainer, + "SSTabRestored" + ); + + newWin2 = await promiseNewWindowLoaded(); + + is( + TEST_URLS.indexOf(newWin2.gBrowser.browsers[0].currentURI.spec), + -1, + "Did not restore, as undoCloseWindow() was last called (2)" + ); + + counts = getBrowserWindowsCount(); + is(counts.open, 2, "Got right number of open windows"); + is(counts.winstates, 3, "Got right number of window states"); + + await BrowserTestUtils.closeWindow(newWin); + await BrowserTestUtils.closeWindow(newWin2); + + counts = getBrowserWindowsCount(); + is(counts.open, 0, "Got right number of open windows"); + is(counts.winstates, 1, "Got right number of window states"); + }); +}); + +/** + * Test if closing can be denied on Mac. + * @note: Mac only + */ +add_task(async function test_mac_notifications() { + if (!IS_MAC) { + return; + } + + await setupTest({ denyFirst: true }, async function (newWin, obs) { + let closed = await closeWindowForRestoration(newWin); + ok(!closed, "First close attempt should be denied"); + closed = await closeWindowForRestoration(newWin); + ok(closed, "Second close attempt should be granted"); + + // We tried closing once, and got denied. Then we tried again and + // succeeded. That means 2 close requests, and 1 close granted. + is( + obs["browser-lastwindow-close-requested"], + 2, + "Got expected browser-lastwindow-close-requested notifications" + ); + is( + obs["browser-lastwindow-close-granted"], + 1, + "Got expected browser-lastwindow-close-granted notifications" + ); + }); +}); diff --git a/browser/components/sessionstore/test/browser_367052.js b/browser/components/sessionstore/test/browser_367052.js new file mode 100644 index 0000000000..67623bd3ab --- /dev/null +++ b/browser/components/sessionstore/test/browser_367052.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"; + +add_task(async function () { + // make sure that the next closed tab will increase getClosedTabCountForWindow + let max_tabs_undo = Services.prefs.getIntPref( + "browser.sessionstore.max_tabs_undo" + ); + Services.prefs.setIntPref( + "browser.sessionstore.max_tabs_undo", + max_tabs_undo + 1 + ); + registerCleanupFunction(() => + Services.prefs.clearUserPref("browser.sessionstore.max_tabs_undo") + ); + + forgetClosedTabs(window); + + // restore a blank tab + let tab = BrowserTestUtils.addTab(gBrowser, "about:mozilla"); + await promiseBrowserLoaded(tab.linkedBrowser); + + let count = await promiseSHistoryCount(tab.linkedBrowser); + ok(count >= 1, "the new tab does have at least one history entry"); + + await promiseTabState(tab, { entries: [] }); + + // We may have a different sessionHistory object if the tab + // switched from non-remote to remote. + count = await promiseSHistoryCount(tab.linkedBrowser); + is(count, 0, "the tab was restored without any history whatsoever"); + + await promiseRemoveTabAndSessionState(tab); + is( + ss.getClosedTabCountForWindow(window), + 0, + "The closed blank tab wasn't added to Recently Closed Tabs" + ); +}); + +function promiseSHistoryCount(browser) { + return SpecialPowers.spawn(browser, [], async function () { + return docShell.QueryInterface(Ci.nsIWebNavigation).sessionHistory.count; + }); +} diff --git a/browser/components/sessionstore/test/browser_393716.js b/browser/components/sessionstore/test/browser_393716.js new file mode 100644 index 0000000000..383c25e385 --- /dev/null +++ b/browser/components/sessionstore/test/browser_393716.js @@ -0,0 +1,103 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const URL = "about:config"; + +add_setup(async function () { + // Make sure that the field of which we restore the state is visible on load. + await SpecialPowers.pushPrefEnv({ + set: [["browser.aboutConfig.showWarning", false]], + }); +}); + +/** + * Bug 393716 - Basic tests for getTabState(), setTabState(), and duplicateTab(). + */ +add_task(async function test_set_tabstate() { + let key = "Unique key: " + Date.now(); + let value = "Unique value: " + Math.random(); + + // create a new tab + let tab = BrowserTestUtils.addTab(gBrowser, URL); + ss.setCustomTabValue(tab, key, value); + await promiseBrowserLoaded(tab.linkedBrowser); + + // get the tab's state + await TabStateFlusher.flush(tab.linkedBrowser); + let state = ss.getTabState(tab); + ok(state, "get the tab's state"); + + // verify the tab state's integrity + state = JSON.parse(state); + ok( + state instanceof Object && + state.entries instanceof Array && + !!state.entries.length, + "state object seems valid" + ); + ok( + state.entries.length == 1 && state.entries[0].url == URL, + "Got the expected state object (test URL)" + ); + ok( + state.extData && state.extData[key] == value, + "Got the expected state object (test manually set tab value)" + ); + + // clean up + gBrowser.removeTab(tab); +}); + +add_task(async function test_set_tabstate_and_duplicate() { + let key2 = "key2"; + let value2 = "Value " + Math.random(); + let value3 = "Another value: " + Date.now(); + let state = { + entries: [{ url: URL, triggeringPrincipal_base64 }], + extData: { key2: value2 }, + }; + + // create a new tab + let tab = BrowserTestUtils.addTab(gBrowser); + // set the tab's state + ss.setTabState(tab, JSON.stringify(state)); + await promiseBrowserLoaded(tab.linkedBrowser); + + // verify the correctness of the restored tab + ok( + ss.getCustomTabValue(tab, key2) == value2 && + tab.linkedBrowser.currentURI.spec == URL, + "the tab's state was correctly restored" + ); + + // add text data + await setPropertyOfFormField( + tab.linkedBrowser, + "#about-config-search", + "value", + value3 + ); + + // duplicate the tab + let tab2 = ss.duplicateTab(window, tab); + await promiseTabRestored(tab2); + + // verify the correctness of the duplicated tab + ok( + ss.getCustomTabValue(tab2, key2) == value2 && + tab2.linkedBrowser.currentURI.spec == URL, + "correctly duplicated the tab's state" + ); + let textbox = await getPropertyOfFormField( + tab2.linkedBrowser, + "#about-config-search", + "value" + ); + is(textbox, value3, "also duplicated text data"); + + // clean up + gBrowser.removeTab(tab2); + gBrowser.removeTab(tab); +}); diff --git a/browser/components/sessionstore/test/browser_394759_basic.js b/browser/components/sessionstore/test/browser_394759_basic.js new file mode 100644 index 0000000000..62d5c40e17 --- /dev/null +++ b/browser/components/sessionstore/test/browser_394759_basic.js @@ -0,0 +1,123 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 TEST_URL = + "data:text/html;charset=utf-8," + + ""; + +/** + * This test ensures that closing a window is a reversible action. We will + * close the the window, restore it and check that all data has been restored. + * This includes window-specific data as well as form data for tabs. + */ +function test() { + waitForExplicitFinish(); + + let uniqueKey = "bug 394759"; + let uniqueValue = "unik" + Date.now(); + let uniqueText = "pi != " + Math.random(); + + // Clear the list of closed windows. + forgetClosedWindows(); + + provideWindow(function onTestURLLoaded(newWin) { + BrowserTestUtils.addTab(newWin.gBrowser).linkedBrowser.stop(); + + // Mark the window with some unique data to be restored later on. + ss.setCustomWindowValue(newWin, uniqueKey, uniqueValue); + let [txt] = newWin.content.document.querySelectorAll("#txt"); + txt.value = uniqueText; + + let browser = newWin.gBrowser.selectedBrowser; + + setPropertyOfFormField(browser, "#chk", "checked", true).then(() => { + BrowserTestUtils.closeWindow(newWin).then(() => { + is( + ss.getClosedWindowCount(), + 1, + "The closed window was added to Recently Closed Windows" + ); + + let data = SessionStore.getClosedWindowData(); + + // Verify that non JSON serialized data is the same as JSON serialized data. + is( + JSON.stringify(data), + ss.getClosedWindowData(), + "Non-serialized data is the same as serialized data" + ); + + ok( + data[0].title == TEST_URL && + JSON.stringify(data[0]).indexOf(uniqueText) > -1, + "The closed window data was stored correctly" + ); + + // Reopen the closed window and ensure its integrity. + let newWin2 = ss.undoCloseWindow(0); + + ok( + newWin2.isChromeWindow, + "undoCloseWindow actually returned a window" + ); + is( + ss.getClosedWindowCount(), + 0, + "The reopened window was removed from Recently Closed Windows" + ); + + // SSTabRestored will fire more than once, so we need to make sure we count them. + let restoredTabs = 0; + let expectedTabs = data[0].tabs.length; + newWin2.addEventListener( + "SSTabRestored", + function sstabrestoredListener(aEvent) { + ++restoredTabs; + info("Restored tab " + restoredTabs + "/" + expectedTabs); + if (restoredTabs < expectedTabs) { + return; + } + + is(restoredTabs, expectedTabs, "Correct number of tabs restored"); + newWin2.removeEventListener( + "SSTabRestored", + sstabrestoredListener, + true + ); + + is( + newWin2.gBrowser.tabs.length, + 2, + "The window correctly restored 2 tabs" + ); + is( + newWin2.gBrowser.currentURI.spec, + TEST_URL, + "The window correctly restored the URL" + ); + + let chk; + [txt, chk] = + newWin2.content.document.querySelectorAll("#txt, #chk"); + ok( + txt.value == uniqueText && chk.checked, + "The window correctly restored the form" + ); + is( + ss.getCustomWindowValue(newWin2, uniqueKey), + uniqueValue, + "The window correctly restored the data associated with it" + ); + + // Clean up. + BrowserTestUtils.closeWindow(newWin2).then(finish); + }, + true + ); + }); + }); + }, TEST_URL); +} diff --git a/browser/components/sessionstore/test/browser_394759_behavior.js b/browser/components/sessionstore/test/browser_394759_behavior.js new file mode 100644 index 0000000000..ee4b121e84 --- /dev/null +++ b/browser/components/sessionstore/test/browser_394759_behavior.js @@ -0,0 +1,91 @@ +/** + * Test helper function that opens a series of windows, closes them + * and then checks the closed window data from SessionStore against + * expected results. + * + * @param windowsToOpen (Array) + * An array of Objects, where each object must define a single + * property "isPopup" for whether or not the opened window should + * be a popup. + * @param expectedResults (Array) + * An Object with two properies: mac and other, where each points + * at yet another Object, with the following properties: + * + * popup (int): + * The number of popup windows we expect to be in the closed window + * data. + * normal (int): + * The number of normal windows we expect to be in the closed window + * data. + * @returns Promise + */ +function testWindows(windowsToOpen, expectedResults) { + return (async function () { + let num = 0; + for (let winData of windowsToOpen) { + let features = "chrome,dialog=no," + (winData.isPopup ? "all=no" : "all"); + let url = "http://example.com/?window=" + num; + num = num + 1; + + let openWindowPromise = BrowserTestUtils.waitForNewWindow({ url }); + openDialog(AppConstants.BROWSER_CHROME_URL, "", features, url); + let win = await openWindowPromise; + await BrowserTestUtils.closeWindow(win); + } + + let closedWindowData = ss.getClosedWindowData(); + let numPopups = closedWindowData.filter(function (el, i, arr) { + return el.isPopup; + }).length; + let numNormal = ss.getClosedWindowCount() - numPopups; + // #ifdef doesn't work in browser-chrome tests, so do a simple regex on platform + let oResults = navigator.platform.match(/Mac/) + ? expectedResults.mac + : expectedResults.other; + is( + numPopups, + oResults.popup, + "There were " + oResults.popup + " popup windows to reopen" + ); + is( + numNormal, + oResults.normal, + "There were " + oResults.normal + " normal windows to repoen" + ); + })(); +} + +add_task(async function test_closed_window_states() { + // This test takes quite some time, and timeouts frequently, so we require + // more time to run. + // See Bug 518970. + requestLongerTimeout(2); + + let windowsToOpen = [ + { isPopup: false }, + { isPopup: false }, + { isPopup: true }, + { isPopup: true }, + { isPopup: true }, + ]; + let expectedResults = { + mac: { popup: 3, normal: 0 }, + other: { popup: 3, normal: 1 }, + }; + + await testWindows(windowsToOpen, expectedResults); + + let windowsToOpen2 = [ + { isPopup: false }, + { isPopup: false }, + { isPopup: false }, + { isPopup: false }, + { isPopup: false }, + ]; + let expectedResults2 = { + mac: { popup: 0, normal: 3 }, + other: { popup: 0, normal: 3 }, + }; + + await testWindows(windowsToOpen2, expectedResults2); +}); diff --git a/browser/components/sessionstore/test/browser_394759_perwindowpb.js b/browser/components/sessionstore/test/browser_394759_perwindowpb.js new file mode 100644 index 0000000000..b4998f2ed8 --- /dev/null +++ b/browser/components/sessionstore/test/browser_394759_perwindowpb.js @@ -0,0 +1,57 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const TESTS = [ + { url: "about:config", key: "bug 394759 Non-PB", value: "uniq" + r() }, + { url: "about:mozilla", key: "bug 394759 PB", value: "uniq" + r() }, +]; + +function promiseTestOpenCloseWindow(aIsPrivate, aTest) { + return (async function () { + let win = await BrowserTestUtils.openNewBrowserWindow({ + private: aIsPrivate, + }); + BrowserTestUtils.loadURIString(win.gBrowser.selectedBrowser, aTest.url); + await promiseBrowserLoaded(win.gBrowser.selectedBrowser, true, aTest.url); + // Mark the window with some unique data to be restored later on. + ss.setCustomWindowValue(win, aTest.key, aTest.value); + await TabStateFlusher.flushWindow(win); + // Close. + await BrowserTestUtils.closeWindow(win); + })(); +} + +function promiseTestOnWindow(aIsPrivate, aValue) { + return (async function () { + let win = await BrowserTestUtils.openNewBrowserWindow({ + private: aIsPrivate, + }); + await TabStateFlusher.flushWindow(win); + let data = ss.getClosedWindowData()[0]; + is( + ss.getClosedWindowCount(), + 1, + "Check that the closed window count hasn't changed" + ); + ok( + JSON.stringify(data).indexOf(aValue) > -1, + "Check the closed window data was stored correctly" + ); + registerCleanupFunction(() => BrowserTestUtils.closeWindow(win)); + })(); +} + +add_setup(async function () { + forgetClosedWindows(); + forgetClosedTabs(window); +}); + +add_task(async function main() { + await promiseTestOpenCloseWindow(false, TESTS[0]); + await promiseTestOpenCloseWindow(true, TESTS[1]); + await promiseTestOnWindow(false, TESTS[0].value); + await promiseTestOnWindow(true, TESTS[0].value); +}); diff --git a/browser/components/sessionstore/test/browser_394759_purge.js b/browser/components/sessionstore/test/browser_394759_purge.js new file mode 100644 index 0000000000..e5218c9936 --- /dev/null +++ b/browser/components/sessionstore/test/browser_394759_purge.js @@ -0,0 +1,247 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 { ForgetAboutSite } = ChromeUtils.importESModule( + "resource://gre/modules/ForgetAboutSite.sys.mjs" +); + +function promiseClearHistory() { + return new Promise(resolve => { + let observer = { + observe(aSubject, aTopic, aData) { + Services.obs.removeObserver( + this, + "browser:purge-session-history-for-domain" + ); + resolve(); + }, + }; + Services.obs.addObserver( + observer, + "browser:purge-session-history-for-domain" + ); + }); +} + +add_task(async function () { + // utility functions + function countClosedTabsByTitle(aClosedTabList, aTitle) { + return aClosedTabList.filter(aData => aData.title == aTitle).length; + } + + function countOpenTabsByTitle(aOpenTabList, aTitle) { + return aOpenTabList.filter(aData => + aData.entries.some(aEntry => aEntry.title == aTitle) + ).length; + } + + // backup old state + let oldState = ss.getBrowserState(); + let oldState_wins = JSON.parse(oldState).windows.length; + if (oldState_wins != 1) { + ok( + false, + "oldState in test_purge has " + oldState_wins + " windows instead of 1" + ); + } + + // create a new state for testing + const REMEMBER = Date.now(), + FORGET = Math.random(); + let testState = { + windows: [ + { + tabs: [ + { + entries: [ + { url: "http://example.com/", triggeringPrincipal_base64 }, + ], + }, + ], + selected: 1, + }, + ], + _closedWindows: [ + // _closedWindows[0] + { + tabs: [ + { + entries: [ + { + url: "http://example.com/", + triggeringPrincipal_base64, + title: REMEMBER, + }, + ], + }, + { + entries: [ + { + url: "http://mozilla.org/", + triggeringPrincipal_base64, + title: FORGET, + }, + ], + }, + ], + selected: 2, + title: "mozilla.org", + _closedTabs: [], + }, + // _closedWindows[1] + { + tabs: [ + { + entries: [ + { + url: "http://mozilla.org/", + triggeringPrincipal_base64, + title: FORGET, + }, + ], + }, + { + entries: [ + { + url: "http://example.com/", + triggeringPrincipal_base64, + title: REMEMBER, + }, + ], + }, + { + entries: [ + { + url: "http://example.com/", + triggeringPrincipal_base64, + title: REMEMBER, + }, + ], + }, + { + entries: [ + { + url: "http://mozilla.org/", + triggeringPrincipal_base64, + title: FORGET, + }, + ], + }, + { + entries: [ + { + url: "http://example.com/", + triggeringPrincipal_base64, + title: REMEMBER, + }, + ], + }, + ], + selected: 5, + _closedTabs: [], + }, + // _closedWindows[2] + { + tabs: [ + { + entries: [ + { + url: "http://example.com/", + triggeringPrincipal_base64, + title: REMEMBER, + }, + ], + }, + ], + selected: 1, + _closedTabs: [ + { + state: { + entries: [ + { + url: "http://mozilla.org/", + triggeringPrincipal_base64, + title: FORGET, + }, + { + url: "http://mozilla.org/again", + triggeringPrincipal_base64, + title: "doesn't matter", + }, + ], + }, + pos: 1, + title: FORGET, + }, + { + state: { + entries: [ + { + url: "http://example.com", + triggeringPrincipal_base64, + title: REMEMBER, + }, + ], + }, + title: REMEMBER, + }, + ], + }, + ], + }; + + // set browser to test state + ss.setBrowserState(JSON.stringify(testState)); + + // purge domain & check that we purged correctly for closed windows + let clearHistoryPromise = promiseClearHistory(); + await ForgetAboutSite.removeDataFromDomain("mozilla.org"); + await clearHistoryPromise; + + let closedWindowData = ss.getClosedWindowData(); + + // First set of tests for _closedWindows[0] - tests basics + let win = closedWindowData[0]; + is(win.tabs.length, 1, "1 tab was removed"); + is(countOpenTabsByTitle(win.tabs, FORGET), 0, "The correct tab was removed"); + is( + countOpenTabsByTitle(win.tabs, REMEMBER), + 1, + "The correct tab was remembered" + ); + is(win.selected, 1, "Selected tab has changed"); + is(win.title, REMEMBER, "The window title was correctly updated"); + + // Test more complicated case + win = closedWindowData[1]; + is(win.tabs.length, 3, "2 tabs were removed"); + is( + countOpenTabsByTitle(win.tabs, FORGET), + 0, + "The correct tabs were removed" + ); + is( + countOpenTabsByTitle(win.tabs, REMEMBER), + 3, + "The correct tabs were remembered" + ); + is(win.selected, 3, "Selected tab has changed"); + is(win.title, REMEMBER, "The window title was correctly updated"); + + // Tests handling of _closedTabs + win = closedWindowData[2]; + is( + countClosedTabsByTitle(win._closedTabs, REMEMBER), + 1, + "The correct number of tabs were removed, and the correct ones" + ); + is( + countClosedTabsByTitle(win._closedTabs, FORGET), + 0, + "All tabs to be forgotten were indeed removed" + ); + + // restore pre-test state + ss.setBrowserState(oldState); +}); diff --git a/browser/components/sessionstore/test/browser_423132.js b/browser/components/sessionstore/test/browser_423132.js new file mode 100644 index 0000000000..3a0113d4d0 --- /dev/null +++ b/browser/components/sessionstore/test/browser_423132.js @@ -0,0 +1,52 @@ +"use strict"; + +/** + * Tests that cookies are stored and restored correctly + * by sessionstore (bug 423132). + */ +add_task(async function () { + const testURL = + "http://mochi.test:8888/browser/" + + "browser/components/sessionstore/test/browser_423132_sample.html"; + + Services.cookies.removeAll(); + // make sure that sessionstore.js can be forced to be created by setting + // the interval pref to 0 + await SpecialPowers.pushPrefEnv({ + set: [["browser.sessionstore.interval", 0]], + }); + + let tab = BrowserTestUtils.addTab(gBrowser, testURL); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await TabStateFlusher.flush(tab.linkedBrowser); + + // get the sessionstore state for the window + let state = ss.getBrowserState(); + + // verify our cookie got set during pageload + let i = 0; + for (var cookie of Services.cookies.cookies) { + i++; + } + Assert.equal(i, 1, "expected one cookie"); + + // remove the cookie + Services.cookies.removeAll(); + + // restore the window state + await setBrowserState(state); + + // at this point, the cookie should be restored... + for (var cookie2 of Services.cookies.cookies) { + if (cookie.name == cookie2.name) { + break; + } + } + is(cookie.name, cookie2.name, "cookie name successfully restored"); + is(cookie.value, cookie2.value, "cookie value successfully restored"); + is(cookie.path, cookie2.path, "cookie path successfully restored"); + + // clean up + Services.cookies.removeAll(); + BrowserTestUtils.removeTab(gBrowser.tabs[1]); +}); diff --git a/browser/components/sessionstore/test/browser_423132_sample.html b/browser/components/sessionstore/test/browser_423132_sample.html new file mode 100644 index 0000000000..6ff7e7aa3e --- /dev/null +++ b/browser/components/sessionstore/test/browser_423132_sample.html @@ -0,0 +1,14 @@ + + + + + + + + diff --git a/browser/components/sessionstore/test/browser_447951.js b/browser/components/sessionstore/test/browser_447951.js new file mode 100644 index 0000000000..aa08f59bbe --- /dev/null +++ b/browser/components/sessionstore/test/browser_447951.js @@ -0,0 +1,84 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +function test() { + /** Test for Bug 447951 **/ + + waitForExplicitFinish(); + const baseURL = + "http://mochi.test:8888/browser/" + + "browser/components/sessionstore/test/browser_447951_sample.html#"; + + // Make sure the functionality added in bug 943339 doesn't affect the results + Services.prefs.setIntPref("browser.sessionstore.max_serialize_back", -1); + Services.prefs.setIntPref("browser.sessionstore.max_serialize_forward", -1); + registerCleanupFunction(function () { + Services.prefs.clearUserPref("browser.sessionstore.max_serialize_back"); + Services.prefs.clearUserPref("browser.sessionstore.max_serialize_forward"); + }); + + let tab = BrowserTestUtils.addTab(gBrowser); + promiseBrowserLoaded(tab.linkedBrowser).then(() => { + let tabState = { entries: [] }; + let max_entries = Services.prefs.getIntPref( + "browser.sessionhistory.max_entries" + ); + for (let i = 0; i < max_entries; i++) { + tabState.entries.push({ url: baseURL + i, triggeringPrincipal_base64 }); + } + + promiseTabState(tab, tabState) + .then(() => { + return TabStateFlusher.flush(tab.linkedBrowser); + }) + .then(() => { + tabState = JSON.parse(ss.getTabState(tab)); + is( + tabState.entries.length, + max_entries, + "session history filled to the limit" + ); + is(tabState.entries[0].url, baseURL + 0, "... but not more"); + + // visit yet another anchor (appending it to session history) + SpecialPowers.spawn(tab.linkedBrowser, [], function () { + content.window.document.querySelector("a").click(); + }).then(flushAndCheck); + + function flushAndCheck() { + TabStateFlusher.flush(tab.linkedBrowser).then(check); + } + + function check() { + tabState = JSON.parse(ss.getTabState(tab)); + if (tab.linkedBrowser.currentURI.spec != baseURL + "end") { + // It may take a few passes through the event loop before we + // get the right URL. + executeSoon(flushAndCheck); + return; + } + + is( + tab.linkedBrowser.currentURI.spec, + baseURL + "end", + "the new anchor was loaded" + ); + is( + tabState.entries[tabState.entries.length - 1].url, + baseURL + "end", + "... and ignored" + ); + is( + tabState.entries[0].url, + baseURL + 1, + "... and the first item was removed" + ); + + // clean up + gBrowser.removeTab(tab); + finish(); + } + }); + }); +} diff --git a/browser/components/sessionstore/test/browser_447951_sample.html b/browser/components/sessionstore/test/browser_447951_sample.html new file mode 100644 index 0000000000..00282f25ef --- /dev/null +++ b/browser/components/sessionstore/test/browser_447951_sample.html @@ -0,0 +1,5 @@ + + +Testcase for bug 447951 + +click me diff --git a/browser/components/sessionstore/test/browser_454908.js b/browser/components/sessionstore/test/browser_454908.js new file mode 100644 index 0000000000..415930c32d --- /dev/null +++ b/browser/components/sessionstore/test/browser_454908.js @@ -0,0 +1,65 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +if (gFissionBrowser) { + addCoopTask( + "browser_454908_sample.html", + test_dont_save_passwords, + HTTPSROOT + ); +} +addNonCoopTask("browser_454908_sample.html", test_dont_save_passwords, ROOT); +addNonCoopTask( + "browser_454908_sample.html", + test_dont_save_passwords, + HTTPROOT +); +addNonCoopTask( + "browser_454908_sample.html", + test_dont_save_passwords, + HTTPSROOT +); + +const PASS = "pwd-" + Math.random(); + +/** + * Bug 454908 - Don't save/restore values of password fields. + */ +async function test_dont_save_passwords(aURL) { + // Make sure we do save form data. + Services.prefs.clearUserPref("browser.sessionstore.privacy_level"); + + // Add a tab with a password field. + let tab = BrowserTestUtils.addTab(gBrowser, aURL); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + + // Fill in some values. + let usernameValue = "User " + Math.random(); + await setPropertyOfFormField(browser, "#username", "value", usernameValue); + await setPropertyOfFormField(browser, "#passwd", "value", PASS); + + // Close and restore the tab. + await promiseRemoveTabAndSessionState(tab); + tab = ss.undoCloseTab(window, 0); + browser = tab.linkedBrowser; + await promiseTabRestored(tab); + + // Check that password fields aren't saved/restored. + let username = await getPropertyOfFormField(browser, "#username", "value"); + is(username, usernameValue, "username was saved/restored"); + let passwd = await getPropertyOfFormField(browser, "#passwd", "value"); + is(passwd, "", "password wasn't saved/restored"); + + // Write to disk and read our file. + await forceSaveState(); + await promiseForEachSessionRestoreFile((state, key) => + // Ensure that we have not saved our password. + ok(!state.includes(PASS), "password has not been written to file " + key) + ); + + // Cleanup. + gBrowser.removeTab(tab); +} diff --git a/browser/components/sessionstore/test/browser_454908_sample.html b/browser/components/sessionstore/test/browser_454908_sample.html new file mode 100644 index 0000000000..02f40bf20b --- /dev/null +++ b/browser/components/sessionstore/test/browser_454908_sample.html @@ -0,0 +1,8 @@ + +Test for bug 454908 + +

Dummy Login

+
+

Username: +

Password: +

diff --git a/browser/components/sessionstore/test/browser_456342.js b/browser/components/sessionstore/test/browser_456342.js new file mode 100644 index 0000000000..e7f1c96e34 --- /dev/null +++ b/browser/components/sessionstore/test/browser_456342.js @@ -0,0 +1,90 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +addCoopTask( + "browser_456342_sample.xhtml", + test_restore_nonstandard_input_values, + HTTPSROOT +); +addNonCoopTask( + "browser_456342_sample.xhtml", + test_restore_nonstandard_input_values, + ROOT +); +addNonCoopTask( + "browser_456342_sample.xhtml", + test_restore_nonstandard_input_values, + HTTPROOT +); +addNonCoopTask( + "browser_456342_sample.xhtml", + test_restore_nonstandard_input_values, + HTTPSROOT +); + +const EXPECTED_IDS = new Set(["searchTerm"]); + +const EXPECTED_XPATHS = new Set([ + "/xhtml:html/xhtml:body/xhtml:form/xhtml:p[2]/xhtml:input", + "/xhtml:html/xhtml:body/xhtml:form/xhtml:p[3]/xhtml:input[@name='fill-in']", + "/xhtml:html/xhtml:body/xhtml:form/xhtml:p[4]/xhtml:input[@name='mistyped']", + "/xhtml:html/xhtml:body/xhtml:form/xhtml:p[5]/xhtml:textarea[@name='textarea_pass']", +]); + +/** + * Bug 456342 - Restore values from non-standard input field types. + */ +async function test_restore_nonstandard_input_values(aURL) { + // Add tab with various non-standard input field types. + let tab = BrowserTestUtils.addTab(gBrowser, aURL); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + + // Fill in form values. + let expectedValue = Math.random(); + + await SpecialPowers.spawn(browser, [expectedValue], valueChild => { + for (let elem of content.document.forms[0].elements) { + elem.value = valueChild; + let event = elem.ownerDocument.createEvent("UIEvents"); + event.initUIEvent("input", true, true, elem.ownerGlobal, 0); + elem.dispatchEvent(event); + } + }); + + // Remove tab and check collected form data. + await promiseRemoveTabAndSessionState(tab); + let undoItems = ss.getClosedTabDataForWindow(window); + let savedFormData = undoItems[0].state.formdata; + + let foundIds = 0; + for (let id of Object.keys(savedFormData.id)) { + ok(EXPECTED_IDS.has(id), `Check saved ID "${id}" was expected`); + is( + savedFormData.id[id], + "" + expectedValue, + `Check saved value for #${id}` + ); + foundIds++; + } + + let foundXpaths = 0; + for (let exp of Object.keys(savedFormData.xpath)) { + ok(EXPECTED_XPATHS.has(exp), `Check saved xpath "${exp}" was expected`); + is( + savedFormData.xpath[exp], + "" + expectedValue, + `Check saved value for ${exp}` + ); + foundXpaths++; + } + + is(foundIds, EXPECTED_IDS.size, "Check number of fields saved by ID"); + is( + foundXpaths, + EXPECTED_XPATHS.size, + "Check number of fields saved by xpath" + ); +} diff --git a/browser/components/sessionstore/test/browser_456342_sample.xhtml b/browser/components/sessionstore/test/browser_456342_sample.xhtml new file mode 100644 index 0000000000..ea8704d17d --- /dev/null +++ b/browser/components/sessionstore/test/browser_456342_sample.xhtml @@ -0,0 +1,46 @@ + + + +Test for bug 456342 + + +
+

Non-standard <input>s

+

Search

+

Image Search:

+

Autocomplete:

+

Mistyped:

+

Invalid attr: + + + +

File Selector

+ + diff --git a/browser/components/sessionstore/test/browser_frame_history.js b/browser/components/sessionstore/test/browser_frame_history.js new file mode 100644 index 0000000000..1db32e74ab --- /dev/null +++ b/browser/components/sessionstore/test/browser_frame_history.js @@ -0,0 +1,230 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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-disable mozilla/no-arbitrary-setTimeout */ + +/** + Ensure that frameset history works properly when restoring a tab, + provided that the frameset is static. + */ + +// Loading a toplevel frameset +add_task(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["browser.navigation.requireUserInteraction", false]], + }); + + let testURL = + getRootDirectory(gTestPath) + "browser_frame_history_index.html"; + let tab = BrowserTestUtils.addTab(gBrowser, testURL); + gBrowser.selectedTab = tab; + + info("Opening a page with three frames, 4 loads should take place"); + await waitForLoadsInBrowser(tab.linkedBrowser, 4); + + let browser_b = + tab.linkedBrowser.contentDocument.getElementsByTagName("frame")[1]; + let document_b = browser_b.contentDocument; + let links = document_b.getElementsByTagName("a"); + + // We're going to click on the first link, so listen for another load event + info("Clicking on link 1, 1 load should take place"); + let promise = waitForLoadsInBrowser(tab.linkedBrowser, 1); + EventUtils.sendMouseEvent( + { type: "click" }, + links[0], + browser_b.contentWindow + ); + await promise; + + info("Clicking on link 2, 1 load should take place"); + promise = waitForLoadsInBrowser(tab.linkedBrowser, 1); + EventUtils.sendMouseEvent( + { type: "click" }, + links[1], + browser_b.contentWindow + ); + await promise; + + info("Close then un-close page, 4 loads should take place"); + await promiseRemoveTabAndSessionState(tab); + let newTab = ss.undoCloseTab(window, 0); + await waitForLoadsInBrowser(newTab.linkedBrowser, 4); + + info("Go back in time, 1 load should take place"); + gBrowser.goBack(); + await waitForLoadsInBrowser(newTab.linkedBrowser, 1); + + let expectedURLEnds = ["a.html", "b.html", "c1.html"]; + let frames = + newTab.linkedBrowser.contentDocument.getElementsByTagName("frame"); + for (let i = 0; i < frames.length; i++) { + is( + frames[i].contentDocument.location.href, + getRootDirectory(gTestPath) + + "browser_frame_history_" + + expectedURLEnds[i], + "frame " + i + " has the right url" + ); + } + gBrowser.removeTab(newTab); +}); + +// Loading the frameset inside an iframe +add_task(async function () { + let testURL = + getRootDirectory(gTestPath) + "browser_frame_history_index2.html"; + let tab = BrowserTestUtils.addTab(gBrowser, testURL); + gBrowser.selectedTab = tab; + + info( + "iframe: Opening a page with an iframe containing three frames, 5 loads should take place" + ); + await waitForLoadsInBrowser(tab.linkedBrowser, 5); + + let browser_b = tab.linkedBrowser.contentDocument + .getElementById("iframe") + .contentDocument.getElementsByTagName("frame")[1]; + let document_b = browser_b.contentDocument; + let links = document_b.getElementsByTagName("a"); + + // We're going to click on the first link, so listen for another load event + info("iframe: Clicking on link 1, 1 load should take place"); + let promise = waitForLoadsInBrowser(tab.linkedBrowser, 1); + EventUtils.sendMouseEvent( + { type: "click" }, + links[0], + browser_b.contentWindow + ); + await promise; + + info("iframe: Clicking on link 2, 1 load should take place"); + promise = waitForLoadsInBrowser(tab.linkedBrowser, 1); + EventUtils.sendMouseEvent( + { type: "click" }, + links[1], + browser_b.contentWindow + ); + await promise; + + info("iframe: Close then un-close page, 5 loads should take place"); + await promiseRemoveTabAndSessionState(tab); + let newTab = ss.undoCloseTab(window, 0); + await waitForLoadsInBrowser(newTab.linkedBrowser, 5); + + info("iframe: Go back in time, 1 load should take place"); + gBrowser.goBack(); + await waitForLoadsInBrowser(newTab.linkedBrowser, 1); + + let expectedURLEnds = ["a.html", "b.html", "c1.html"]; + let frames = newTab.linkedBrowser.contentDocument + .getElementById("iframe") + .contentDocument.getElementsByTagName("frame"); + for (let i = 0; i < frames.length; i++) { + is( + frames[i].contentDocument.location.href, + getRootDirectory(gTestPath) + + "browser_frame_history_" + + expectedURLEnds[i], + "frame " + i + " has the right url" + ); + } + gBrowser.removeTab(newTab); +}); + +// Now, test that we don't record history if the iframe is added dynamically +add_task(async function () { + // Start with an empty history + let blankState = JSON.stringify({ + windows: [ + { + tabs: [ + { entries: [{ url: "about:blank", triggeringPrincipal_base64 }] }, + ], + _closedTabs: [], + }, + ], + _closedWindows: [], + }); + await setBrowserState(blankState); + + let testURL = + getRootDirectory(gTestPath) + "browser_frame_history_index_blank.html"; + let tab = BrowserTestUtils.addTab(gBrowser, testURL); + gBrowser.selectedTab = tab; + await waitForLoadsInBrowser(tab.linkedBrowser, 1); + + info( + "dynamic: Opening a page with an iframe containing three frames, 4 dynamic loads should take place" + ); + let doc = tab.linkedBrowser.contentDocument; + let iframe = doc.createElement("iframe"); + iframe.id = "iframe"; + iframe.src = "browser_frame_history_index.html"; + doc.body.appendChild(iframe); + await waitForLoadsInBrowser(tab.linkedBrowser, 4); + + let browser_b = tab.linkedBrowser.contentDocument + .getElementById("iframe") + .contentDocument.getElementsByTagName("frame")[1]; + let document_b = browser_b.contentDocument; + let links = document_b.getElementsByTagName("a"); + + // We're going to click on the first link, so listen for another load event + info("dynamic: Clicking on link 1, 1 load should take place"); + let promise = waitForLoadsInBrowser(tab.linkedBrowser, 1); + EventUtils.sendMouseEvent( + { type: "click" }, + links[0], + browser_b.contentWindow + ); + await promise; + + info("dynamic: Clicking on link 2, 1 load should take place"); + promise = waitForLoadsInBrowser(tab.linkedBrowser, 1); + EventUtils.sendMouseEvent( + { type: "click" }, + links[1], + browser_b.contentWindow + ); + await promise; + + info("Check in the state that we have not stored this history"); + let state = ss.getBrowserState(); + info(JSON.stringify(JSON.parse(state), null, "\t")); + is( + state.indexOf("c1.html"), + -1, + "History entry was not stored in the session state" + ); + gBrowser.removeTab(tab); +}); + +// helper functions +function waitForLoadsInBrowser(aBrowser, aLoadCount) { + return new Promise(resolve => { + let loadCount = 0; + aBrowser.addEventListener( + "load", + function listener(aEvent) { + if (++loadCount < aLoadCount) { + info( + "Got " + loadCount + " loads, waiting until we have " + aLoadCount + ); + return; + } + + aBrowser.removeEventListener("load", listener, true); + resolve(); + }, + true + ); + }); +} + +function timeout(delay, task) { + return new Promise((resolve, reject) => { + setTimeout(() => resolve(true), delay); + task.then(() => resolve(false), reject); + }); +} diff --git a/browser/components/sessionstore/test/browser_frame_history_a.html b/browser/components/sessionstore/test/browser_frame_history_a.html new file mode 100644 index 0000000000..8e7b35d7a1 --- /dev/null +++ b/browser/components/sessionstore/test/browser_frame_history_a.html @@ -0,0 +1,5 @@ + + + I'm A! + + diff --git a/browser/components/sessionstore/test/browser_frame_history_b.html b/browser/components/sessionstore/test/browser_frame_history_b.html new file mode 100644 index 0000000000..38b43da211 --- /dev/null +++ b/browser/components/sessionstore/test/browser_frame_history_b.html @@ -0,0 +1,10 @@ + + + I'm B!
+ click me first
+ then click me
+ Close this tab.
+ Restore this tab.
+ Click back.
+ + diff --git a/browser/components/sessionstore/test/browser_frame_history_c.html b/browser/components/sessionstore/test/browser_frame_history_c.html new file mode 100644 index 0000000000..0efd7d9026 --- /dev/null +++ b/browser/components/sessionstore/test/browser_frame_history_c.html @@ -0,0 +1,5 @@ + + + I'm C! + + diff --git a/browser/components/sessionstore/test/browser_frame_history_c1.html b/browser/components/sessionstore/test/browser_frame_history_c1.html new file mode 100644 index 0000000000..b55c1d45a9 --- /dev/null +++ b/browser/components/sessionstore/test/browser_frame_history_c1.html @@ -0,0 +1,5 @@ + + + I'm C1! + + diff --git a/browser/components/sessionstore/test/browser_frame_history_c2.html b/browser/components/sessionstore/test/browser_frame_history_c2.html new file mode 100644 index 0000000000..aec504141b --- /dev/null +++ b/browser/components/sessionstore/test/browser_frame_history_c2.html @@ -0,0 +1,5 @@ + + + I'm C2! + + diff --git a/browser/components/sessionstore/test/browser_frame_history_index.html b/browser/components/sessionstore/test/browser_frame_history_index.html new file mode 100644 index 0000000000..04a44555ab --- /dev/null +++ b/browser/components/sessionstore/test/browser_frame_history_index.html @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/browser/components/sessionstore/test/browser_frame_history_index2.html b/browser/components/sessionstore/test/browser_frame_history_index2.html new file mode 100644 index 0000000000..d465abef62 --- /dev/null +++ b/browser/components/sessionstore/test/browser_frame_history_index2.html @@ -0,0 +1,3 @@ + + + + diff --git a/browser/components/sessionstore/test/browser_global_store.js b/browser/components/sessionstore/test/browser_global_store.js new file mode 100644 index 0000000000..99aa672180 --- /dev/null +++ b/browser/components/sessionstore/test/browser_global_store.js @@ -0,0 +1,53 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests the API for saving global session data. +add_task(async function () { + const key1 = "Unique name 1: " + Date.now(); + const key2 = "Unique name 2: " + Date.now(); + const value1 = "Unique value 1: " + Math.random(); + const value2 = "Unique value 2: " + Math.random(); + + let global = {}; + global[key1] = value1; + + const testState = { + windows: [ + { + tabs: [ + { entries: [{ url: "about:blank", triggeringPrincipal_base64 }] }, + ], + }, + ], + global, + }; + + function testRestoredState() { + is( + ss.getCustomGlobalValue(key1), + value1, + "restored state has global value" + ); + } + + function testGlobalStore() { + is(ss.getCustomGlobalValue(key2), "", "global value initially not set"); + + ss.setCustomGlobalValue(key2, value1); + is(ss.getCustomGlobalValue(key2), value1, "retreived value matches stored"); + + ss.setCustomGlobalValue(key2, value2); + is( + ss.getCustomGlobalValue(key2), + value2, + "previously stored value was overwritten" + ); + + ss.deleteCustomGlobalValue(key2); + is(ss.getCustomGlobalValue(key2), "", "global value was deleted"); + } + + await promiseBrowserState(testState); + testRestoredState(); + testGlobalStore(); +}); diff --git a/browser/components/sessionstore/test/browser_history_persist.js b/browser/components/sessionstore/test/browser_history_persist.js new file mode 100644 index 0000000000..b45a2b6779 --- /dev/null +++ b/browser/components/sessionstore/test/browser_history_persist.js @@ -0,0 +1,163 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Ensure that history entries that should not be persisted are restored in the + * same state. + */ +add_task(async function check_history_not_persisted() { + // Create an about:blank tab + let tab = BrowserTestUtils.addTab(gBrowser, "about:blank"); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + + // Retrieve the tab state. + await TabStateFlusher.flush(browser); + let state = JSON.parse(ss.getTabState(tab)); + ok(!state.entries[0].persist, "Should have collected the persistence state"); + BrowserTestUtils.removeTab(tab); + browser = null; + + // Open a new tab to restore into. + tab = BrowserTestUtils.addTab(gBrowser, "about:blank"); + browser = tab.linkedBrowser; + await promiseTabState(tab, state); + + if (!SpecialPowers.Services.appinfo.sessionHistoryInParent) { + await SpecialPowers.spawn(browser, [], function () { + let sessionHistory = + docShell.browsingContext.childSessionHistory.legacySHistory; + + is(sessionHistory.count, 1, "Should be a single history entry"); + is( + sessionHistory.getEntryAtIndex(0).URI.spec, + "about:blank", + "Should be the right URL" + ); + }); + } else { + let sessionHistory = browser.browsingContext.sessionHistory; + + is(sessionHistory.count, 1, "Should be a single history entry"); + is( + sessionHistory.getEntryAtIndex(0).URI.spec, + "about:blank", + "Should be the right URL" + ); + } + + // Load a new URL into the tab, it should replace the about:blank history entry + BrowserTestUtils.loadURIString(browser, "about:robots"); + await promiseBrowserLoaded(browser, false, "about:robots"); + if (!SpecialPowers.Services.appinfo.sessionHistoryInParent) { + await SpecialPowers.spawn(browser, [], function () { + let sessionHistory = + docShell.browsingContext.childSessionHistory.legacySHistory; + + is(sessionHistory.count, 1, "Should be a single history entry"); + is( + sessionHistory.getEntryAtIndex(0).URI.spec, + "about:robots", + "Should be the right URL" + ); + }); + } else { + let sessionHistory = browser.browsingContext.sessionHistory; + + is(sessionHistory.count, 1, "Should be a single history entry"); + is( + sessionHistory.getEntryAtIndex(0).URI.spec, + "about:robots", + "Should be the right URL" + ); + } + + // Cleanup. + BrowserTestUtils.removeTab(tab); +}); + +/** + * Check that entries default to being persisted when the attribute doesn't + * exist + */ +add_task(async function check_history_default_persisted() { + // Create an about:blank tab + let tab = BrowserTestUtils.addTab(gBrowser, "about:blank"); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + + // Retrieve the tab state. + await TabStateFlusher.flush(browser); + let state = JSON.parse(ss.getTabState(tab)); + delete state.entries[0].persist; + BrowserTestUtils.removeTab(tab); + browser = null; + + // Open a new tab to restore into. + tab = BrowserTestUtils.addTab(gBrowser, "about:blank"); + browser = tab.linkedBrowser; + await promiseTabState(tab, state); + if (!SpecialPowers.Services.appinfo.sessionHistoryInParent) { + await SpecialPowers.spawn(browser, [], function () { + let sessionHistory = + docShell.browsingContext.childSessionHistory.legacySHistory; + + is(sessionHistory.count, 1, "Should be a single history entry"); + is( + sessionHistory.getEntryAtIndex(0).URI.spec, + "about:blank", + "Should be the right URL" + ); + }); + } else { + let sessionHistory = browser.browsingContext.sessionHistory; + + is(sessionHistory.count, 1, "Should be a single history entry"); + is( + sessionHistory.getEntryAtIndex(0).URI.spec, + "about:blank", + "Should be the right URL" + ); + } + + // Load a new URL into the tab, it should replace the about:blank history entry + BrowserTestUtils.loadURIString(browser, "about:robots"); + await promiseBrowserLoaded(browser, false, "about:robots"); + if (!SpecialPowers.Services.appinfo.sessionHistoryInParent) { + await SpecialPowers.spawn(browser, [], function () { + let sessionHistory = + docShell.browsingContext.childSessionHistory.legacySHistory; + + is(sessionHistory.count, 2, "Should be two history entries"); + is( + sessionHistory.getEntryAtIndex(0).URI.spec, + "about:blank", + "Should be the right URL" + ); + is( + sessionHistory.getEntryAtIndex(1).URI.spec, + "about:robots", + "Should be the right URL" + ); + }); + } else { + let sessionHistory = browser.browsingContext.sessionHistory; + + is(sessionHistory.count, 2, "Should be two history entries"); + is( + sessionHistory.getEntryAtIndex(0).URI.spec, + "about:blank", + "Should be the right URL" + ); + is( + sessionHistory.getEntryAtIndex(1).URI.spec, + "about:robots", + "Should be the right URL" + ); + } + + // Cleanup. + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/sessionstore/test/browser_ignore_updates_crashed_tabs.js b/browser/components/sessionstore/test/browser_ignore_updates_crashed_tabs.js new file mode 100644 index 0000000000..4230f55f33 --- /dev/null +++ b/browser/components/sessionstore/test/browser_ignore_updates_crashed_tabs.js @@ -0,0 +1,108 @@ +// This test checks that browsers are removed from the SessionStore's +// crashed browser set at a correct time, so that it can stop ignoring update +// events coming from those browsers. + +/** + * Open a tab, crash it, navigate it to a remote uri, and check that it + * is removed from a crashed set. + */ +add_task(async function test_update_crashed_tab_after_navigate_to_remote() { + let tab = BrowserTestUtils.addTab(gBrowser, "https://example.com/"); + let browser = tab.linkedBrowser; + gBrowser.selectedTab = tab; + await promiseBrowserLoaded(browser); + ok( + !SessionStore.isBrowserInCrashedSet(browser), + "browser is not in the crashed set" + ); + + await BrowserTestUtils.crashFrame(browser); + ok( + SessionStore.isBrowserInCrashedSet(browser), + "browser is in the crashed set" + ); + + BrowserTestUtils.loadURIString(browser, "https://example.org/"); + await BrowserTestUtils.browserLoaded(browser, false, "https://example.org/"); + ok( + !SessionStore.isBrowserInCrashedSet(browser), + "browser is not in the crashed set" + ); + ok( + !tab.hasAttribute("crashed"), + "Tab shouldn't be marked as crashed anymore." + ); + gBrowser.removeTab(tab); +}); + +/** + * Open a tab, crash it, navigate it to a non-remote uri, and check that it + * is removed from a crashed set. + */ +add_task(async function test_update_crashed_tab_after_navigate_to_non_remote() { + let tab = BrowserTestUtils.addTab(gBrowser, "https://example.com/"); + let browser = tab.linkedBrowser; + gBrowser.selectedTab = tab; + await promiseBrowserLoaded(browser); + ok( + !SessionStore.isBrowserInCrashedSet(browser), + "browser is not in the crashed set" + ); + + await BrowserTestUtils.crashFrame(browser); + ok( + SessionStore.isBrowserInCrashedSet(browser), + "browser is in the crashed set" + ); + + BrowserTestUtils.loadURIString(browser, "about:mozilla"); + await BrowserTestUtils.browserLoaded(browser, false, "about:mozilla"); + ok( + !SessionStore.isBrowserInCrashedSet(browser), + "browser is not in the crashed set" + ); + ok( + !gBrowser.selectedTab.hasAttribute("crashed"), + "Tab shouldn't be marked as crashed anymore." + ); + gBrowser.removeTab(tab); +}); + +/** + * Open a tab, crash it, restore it from history, and check that it + * is removed from a crashed set. + */ +add_task(async function test_update_crashed_tab_after_session_restore() { + let tab = BrowserTestUtils.addTab(gBrowser, "https://example.com/"); + let browser = tab.linkedBrowser; + gBrowser.selectedTab = tab; + await promiseBrowserLoaded(browser); + ok( + !SessionStore.isBrowserInCrashedSet(browser), + "browser is not in the crashed set" + ); + + await BrowserTestUtils.crashFrame(browser); + ok( + SessionStore.isBrowserInCrashedSet(browser), + "browser is in the crashed set" + ); + + let tabRestoredPromise = promiseTabRestored(tab); + // Click restoreTab button + await SpecialPowers.spawn(browser, [], () => { + let button = content.document.getElementById("restoreTab"); + button.click(); + }); + await tabRestoredPromise; + ok( + !tab.hasAttribute("crashed"), + "Tab shouldn't be marked as crashed anymore." + ); + ok( + !SessionStore.isBrowserInCrashedSet(browser), + "browser is not in the crashed set" + ); + + gBrowser.removeTab(tab); +}); diff --git a/browser/components/sessionstore/test/browser_label_and_icon.js b/browser/components/sessionstore/test/browser_label_and_icon.js new file mode 100644 index 0000000000..9b254c5e77 --- /dev/null +++ b/browser/components/sessionstore/test/browser_label_and_icon.js @@ -0,0 +1,53 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Ensure that a pending tab has label and icon correctly set. + */ +add_task(async function test_label_and_icon() { + // Make sure that tabs are restored on demand as otherwise the tab will start + // loading immediately and we can't check its icon and label. + await SpecialPowers.pushPrefEnv({ + set: [["browser.sessionstore.restore_on_demand", true]], + }); + + // Create a new tab. + let tab = BrowserTestUtils.addTab(gBrowser, "about:robots"); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + // Because there is debounce logic in ContentLinkHandler.jsm to reduce the + // favicon loads, we have to wait some time before checking that icon was + // stored properly. + await BrowserTestUtils.waitForCondition( + () => { + return gBrowser.getIcon(tab) != null; + }, + "wait for favicon load to finish", + 100, + 5 + ); + + // Retrieve the tab state. + await TabStateFlusher.flush(browser); + let state = ss.getTabState(tab); + BrowserTestUtils.removeTab(tab); + browser = null; + + // Open a new tab to restore into. + tab = BrowserTestUtils.addTab(gBrowser, "about:blank"); + ss.setTabState(tab, state); + await promiseTabRestoring(tab); + + // Check that label and icon are set for the restoring tab. + is( + gBrowser.getIcon(tab), + "chrome://browser/content/robot.ico", + "icon is set" + ); + is(tab.label, "Gort! Klaatu barada nikto!", "label is set"); + + // Cleanup. + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/sessionstore/test/browser_merge_closed_tabs.js b/browser/components/sessionstore/test/browser_merge_closed_tabs.js new file mode 100644 index 0000000000..2c6a946cdf --- /dev/null +++ b/browser/components/sessionstore/test/browser_merge_closed_tabs.js @@ -0,0 +1,116 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * This test ensures that closed tabs are merged when restoring + * a window state without overwriting tabs. + */ +add_task(async function () { + const initialState = { + windows: [ + { + tabs: [ + { entries: [{ url: "about:blank", triggeringPrincipal_base64 }] }, + ], + _closedTabs: [ + { + state: { + entries: [ + { ID: 1000, url: "about:blank", triggeringPrincipal_base64 }, + ], + }, + }, + { + state: { + entries: [ + { ID: 1001, url: "about:blank", triggeringPrincipal_base64 }, + ], + }, + }, + ], + }, + ], + }; + + const restoreState = { + windows: [ + { + tabs: [ + { entries: [{ url: "about:robots", triggeringPrincipal_base64 }] }, + ], + _closedTabs: [ + { + state: { + entries: [ + { ID: 1002, url: "about:robots", triggeringPrincipal_base64 }, + ], + }, + }, + { + state: { + entries: [ + { ID: 1003, url: "about:robots", triggeringPrincipal_base64 }, + ], + }, + }, + { + state: { + entries: [ + { ID: 1004, url: "about:robots", triggeringPrincipal_base64 }, + ], + }, + }, + ], + }, + ], + }; + + const maxTabsUndo = 4; + Services.prefs.setIntPref("browser.sessionstore.max_tabs_undo", maxTabsUndo); + + // Open a new window and restore it to an initial state. + let win = await promiseNewWindowLoaded({ private: false }); + await setWindowState(win, initialState, true); + is( + SessionStore.getClosedTabCountForWindow(win), + 2, + "2 closed tabs after restoring initial state" + ); + + // Restore the new state but do not overwrite existing tabs (this should + // cause the closed tabs to be merged). + await setWindowState(win, restoreState); + + // Verify the windows closed tab data is correct. + let iClosed = initialState.windows[0]._closedTabs; + let rClosed = restoreState.windows[0]._closedTabs; + let cData = SessionStore.getClosedTabDataForWindow(win); + + is( + cData.length, + Math.min(iClosed.length + rClosed.length, maxTabsUndo), + "Number of closed tabs is correct" + ); + + // When the closed tabs are merged the restored tabs are considered to be + // closed more recently. + for (let i = 0; i < cData.length; i++) { + if (i < rClosed.length) { + is( + cData[i].state.entries[0].ID, + rClosed[i].state.entries[0].ID, + "Closed tab entry matches" + ); + } else { + is( + cData[i].state.entries[0].ID, + iClosed[i - rClosed.length].state.entries[0].ID, + "Closed tab entry matches" + ); + } + } + + // Clean up. + Services.prefs.clearUserPref("browser.sessionstore.max_tabs_undo"); + await BrowserTestUtils.closeWindow(win); +}); diff --git a/browser/components/sessionstore/test/browser_movePendingTabToNewWindow.js b/browser/components/sessionstore/test/browser_movePendingTabToNewWindow.js new file mode 100644 index 0000000000..3a013132be --- /dev/null +++ b/browser/components/sessionstore/test/browser_movePendingTabToNewWindow.js @@ -0,0 +1,124 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * This tests the behaviour of moving pending tabs to a new window. These + * pending tabs have yet to be restored and should be restored upon opening + * in the new window. This test covers moving a single pending tab at once + * as well as multiple tabs at the same time (using tab multiselection). + */ +add_task(async function test_movePendingTabToNewWindow() { + const TEST_URIS = [ + "http://www.example.com/1", + "http://www.example.com/2", + "http://www.example.com/3", + "http://www.example.com/4", + ]; + + await SpecialPowers.pushPrefEnv({ + set: [["browser.sessionstore.restore_on_demand", true]], + }); + + let state = { + windows: [ + { + tabs: [ + { entries: [{ url: TEST_URIS[0], triggeringPrincipal_base64 }] }, + { entries: [{ url: TEST_URIS[1], triggeringPrincipal_base64 }] }, + { entries: [{ url: TEST_URIS[2], triggeringPrincipal_base64 }] }, + { entries: [{ url: TEST_URIS[3], triggeringPrincipal_base64 }] }, + ], + selected: 4, + }, + ], + }; + + await promiseBrowserState(state); + + is( + gBrowser.visibleTabs.length, + 4, + "Three tabs are visible to start the test" + ); + + let tabToSelect = gBrowser.visibleTabs[1]; + ok(tabToSelect.hasAttribute("pending"), "Tab should be pending"); + + gBrowser.addRangeToMultiSelectedTabs(gBrowser.selectedTab, tabToSelect); + ok(!gBrowser.visibleTabs[0].multiselected, "First tab not multiselected"); + ok(gBrowser.visibleTabs[1].multiselected, "Second tab multiselected"); + ok(gBrowser.visibleTabs[2].multiselected, "Third tab multiselected"); + ok(gBrowser.visibleTabs[3].multiselected, "Fourth tab multiselected"); + + let promiseNewWindow = BrowserTestUtils.waitForNewWindow(); + gBrowser.replaceTabsWithWindow(tabToSelect); + + info("Waiting for new window"); + let newWindow = await promiseNewWindow; + isnot(newWindow, gBrowser.ownerGlobal, "Tab moved to new window"); + + let newWindowTabs = newWindow.gBrowser.visibleTabs; + await TestUtils.waitForCondition(() => { + return ( + newWindowTabs.length == 3 && + newWindowTabs[0].linkedBrowser.currentURI.spec == TEST_URIS[1] && + newWindowTabs[1].linkedBrowser.currentURI.spec == TEST_URIS[2] && + newWindowTabs[2].linkedBrowser.currentURI.spec == TEST_URIS[3] + ); + }, "Wait for all three tabs to move to new window and load"); + + is(newWindowTabs.length, 3, "Three tabs should be in new window"); + is( + newWindowTabs[0].linkedBrowser.currentURI.spec, + TEST_URIS[1], + "Second tab moved" + ); + is( + newWindowTabs[1].linkedBrowser.currentURI.spec, + TEST_URIS[2], + "Third tab moved" + ); + is( + newWindowTabs[2].linkedBrowser.currentURI.spec, + TEST_URIS[3], + "Fourth tab moved" + ); + + ok( + newWindowTabs[0].hasAttribute("pending"), + "First tab in new window should still be pending" + ); + ok( + newWindowTabs[1].hasAttribute("pending"), + "Second tab in new window should still be pending" + ); + newWindow.gBrowser.clearMultiSelectedTabs(); + ok( + newWindowTabs.every(t => !t.multiselected), + "No multiselection should be present" + ); + + promiseNewWindow = BrowserTestUtils.waitForNewWindow(); + newWindow.gBrowser.replaceTabsWithWindow(newWindowTabs[0]); + + info("Waiting for second new window"); + let secondNewWindow = await promiseNewWindow; + await TestUtils.waitForCondition( + () => + secondNewWindow.gBrowser.selectedBrowser.currentURI.spec == TEST_URIS[1], + "Wait until the URI is updated" + ); + is( + secondNewWindow.gBrowser.visibleTabs.length, + 1, + "Only one tab in second new window" + ); + is( + secondNewWindow.gBrowser.selectedBrowser.currentURI.spec, + TEST_URIS[1], + "First tab moved" + ); + + await BrowserTestUtils.closeWindow(secondNewWindow); + await BrowserTestUtils.closeWindow(newWindow); +}); diff --git a/browser/components/sessionstore/test/browser_multiple_navigateAndRestore.js b/browser/components/sessionstore/test/browser_multiple_navigateAndRestore.js new file mode 100644 index 0000000000..c49a424260 --- /dev/null +++ b/browser/components/sessionstore/test/browser_multiple_navigateAndRestore.js @@ -0,0 +1,45 @@ +"use strict"; + +const PAGE_1 = + "data:text/html,A%20regular,%20everyday,%20normal%20page."; +const PAGE_2 = + "data:text/html,Another%20regular,%20everyday,%20normal%20page."; + +add_task(async function () { + // Load an empty, non-remote tab at about:blank... + let tab = BrowserTestUtils.addTab(gBrowser, "about:blank", { + forceNotRemote: true, + }); + gBrowser.selectedTab = tab; + let browser = gBrowser.selectedBrowser; + ok(!browser.isRemoteBrowser, "Ensure browser is not remote"); + // Load a remote page, and then another remote page immediately + // after. + BrowserTestUtils.loadURIString(browser, PAGE_1); + browser.stop(); + BrowserTestUtils.loadURIString(browser, PAGE_2); + await BrowserTestUtils.browserLoaded(browser, false, PAGE_2); + + ok(browser.isRemoteBrowser, "Should have switched remoteness"); + await TabStateFlusher.flush(browser); + let state = JSON.parse(ss.getTabState(tab)); + let entries = state.entries; + is(entries.length, 1, "There should only be one entry"); + is(entries[0].url, PAGE_2, "Should have PAGE_2 as the sole history entry"); + is( + browser.currentURI.spec, + PAGE_2, + "Should have PAGE_2 as the browser currentURI" + ); + + await SpecialPowers.spawn(browser, [PAGE_2], async function (expectedURL) { + docShell.QueryInterface(Ci.nsIWebNavigation); + Assert.equal( + docShell.currentURI.spec, + expectedURL, + "Content should have PAGE_2 as the browser currentURI" + ); + }); + + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/sessionstore/test/browser_multiple_select_after_load.js b/browser/components/sessionstore/test/browser_multiple_select_after_load.js new file mode 100644 index 0000000000..dcb896e435 --- /dev/null +++ b/browser/components/sessionstore/test/browser_multiple_select_after_load.js @@ -0,0 +1,54 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const URL = `data:text/html;charset=utf-8, +`; + +const VALUES = ["1", "3"]; + +// Tests that a document that changes a element and select some + // options. + await setPropertyOfFormField(tab.linkedBrowser, "select", "multiple", true); + + for (let v of VALUES) { + await setPropertyOfFormField( + tab.linkedBrowser, + `option[value="${v}"]`, + "selected", + true + ); + } + + // Remove the tab. + await promiseRemoveTabAndSessionState(tab); + + // Verify state of the closed tab. + let tabData = ss.getClosedTabDataForWindow(window); + Assert.deepEqual( + tabData[0].state.formdata.id.select, + VALUES, + "Collected correct formdata" + ); + + // Restore the close tab. + tab = ss.undoCloseTab(window, 0); + await promiseTabRestored(tab); + ok(true, "Didn't crash!"); + + // Cleanup. + gBrowser.removeTab(tab); +}); diff --git a/browser/components/sessionstore/test/browser_newtab_userTypedValue.js b/browser/components/sessionstore/test/browser_newtab_userTypedValue.js new file mode 100644 index 0000000000..755a1f2859 --- /dev/null +++ b/browser/components/sessionstore/test/browser_newtab_userTypedValue.js @@ -0,0 +1,92 @@ +"use strict"; + +requestLongerTimeout(4); + +/** + * Test that when restoring an 'initial page' with session restore, it + * produces an empty URL bar, rather than leaving its URL explicitly + * there as a 'user typed value'. + */ +add_task(async function () { + let win = await BrowserTestUtils.openNewBrowserWindow(); + await BrowserTestUtils.openNewForegroundTab(win.gBrowser, "about:logo"); + let tabOpenedAndSwitchedTo = BrowserTestUtils.switchTab( + win.gBrowser, + () => {} + ); + + // This opens about:newtab: + win.BrowserOpenTab(); + let tab = await tabOpenedAndSwitchedTo; + is(win.gURLBar.value, "", "URL bar should be empty"); + is(tab.linkedBrowser.userTypedValue, null, "userTypedValue should be null"); + let state = JSON.parse(SessionStore.getTabState(tab)); + ok( + !state.userTypedValue, + "userTypedValue should be undefined on the tab's state" + ); + tab = null; + + await BrowserTestUtils.closeWindow(win); + + ok(SessionStore.getClosedWindowCount(), "Should have a closed window"); + + await forceSaveState(); + + win = SessionStore.undoCloseWindow(0); + await TestUtils.topicObserved( + "sessionstore-single-window-restored", + subject => subject == win + ); + // Don't wait for load here because it's about:newtab and we may have swapped in + // a preloaded browser. + await TabStateFlusher.flush(win.gBrowser.selectedBrowser); + + is(win.gURLBar.value, "", "URL bar should be empty"); + tab = win.gBrowser.selectedTab; + is(tab.linkedBrowser.userTypedValue, null, "userTypedValue should be null"); + state = JSON.parse(SessionStore.getTabState(tab)); + ok( + !state.userTypedValue, + "userTypedValue should be undefined on the tab's state" + ); + + BrowserTestUtils.removeTab(tab); + + for (let url of gInitialPages) { + if (url == BROWSER_NEW_TAB_URL) { + continue; // We tested about:newtab using BrowserOpenTab() above. + } + info("Testing " + url + " - " + new Date()); + await BrowserTestUtils.openNewForegroundTab(win.gBrowser, url); + await BrowserTestUtils.closeWindow(win); + + ok(SessionStore.getClosedWindowCount(), "Should have a closed window"); + + await forceSaveState(); + + win = SessionStore.undoCloseWindow(0); + await TestUtils.topicObserved( + "sessionstore-single-window-restored", + subject => subject == win + ); + await BrowserTestUtils.browserLoaded(win.gBrowser.selectedBrowser); + await TabStateFlusher.flush(win.gBrowser.selectedBrowser); + + is(win.gURLBar.value, "", "URL bar should be empty"); + tab = win.gBrowser.selectedTab; + is(tab.linkedBrowser.userTypedValue, null, "userTypedValue should be null"); + state = JSON.parse(SessionStore.getTabState(tab)); + ok( + !state.userTypedValue, + "userTypedValue should be undefined on the tab's state" + ); + + info("Removing tab - " + new Date()); + BrowserTestUtils.removeTab(tab); + info("Finished removing tab - " + new Date()); + } + info("Removing window - " + new Date()); + await BrowserTestUtils.closeWindow(win); + info("Finished removing window - " + new Date()); +}); diff --git a/browser/components/sessionstore/test/browser_not_collect_when_idle.js b/browser/components/sessionstore/test/browser_not_collect_when_idle.js new file mode 100644 index 0000000000..c4a49ab7b7 --- /dev/null +++ b/browser/components/sessionstore/test/browser_not_collect_when_idle.js @@ -0,0 +1,118 @@ +/** Test for Bug 1305950 **/ + +const { MockRegistrar } = ChromeUtils.importESModule( + "resource://testing-common/MockRegistrar.sys.mjs" +); + +// The mock idle service. +var idleService = { + _observers: new Set(), + _activity: { + addCalls: [], + removeCalls: [], + observerFires: [], + }, + + _reset() { + this._observers.clear(); + this._activity.addCalls = []; + this._activity.removeCalls = []; + this._activity.observerFires = []; + }, + + _fireObservers(state) { + for (let observer of this._observers.values()) { + observer.observe(observer, state, null); + this._activity.observerFires.push(state); + } + }, + + QueryInterface: ChromeUtils.generateQI(["nsIUserIdleService"]), + idleTime: 19999, + + addIdleObserver(observer, time) { + this._observers.add(observer); + this._activity.addCalls.push(time); + }, + + removeIdleObserver(observer, time) { + this._observers.delete(observer); + this._activity.removeCalls.push(time); + }, +}; + +add_task(async function testIntervalChanges() { + const PREF_SS_INTERVAL = 2000; + + // We speed up the interval between session saves to ensure that the test + // runs quickly. + Services.prefs.setIntPref("browser.sessionstore.interval", PREF_SS_INTERVAL); + + // Increase `idleDelay` to 1 day to update the pre-registered idle observer + // in "real" idle service to avoid possible interference, especially for the + // CI server environment. + Services.prefs.setIntPref("browser.sessionstore.idleDelay", 86400); + + // Mock an idle service. + let fakeIdleService = MockRegistrar.register( + "@mozilla.org/widget/useridleservice;1", + idleService + ); + idleService._reset(); + + registerCleanupFunction(function () { + Services.prefs.clearUserPref("browser.sessionstore.interval"); + MockRegistrar.unregister(fakeIdleService); + }); + + // Hook idle/active observer to mock idle service by changing pref `idleDelay` + // to a whatever value, which will not be used. + Services.prefs.setIntPref("browser.sessionstore.idleDelay", 5000); + + // Wait a `sessionstore-state-write-complete` event from any previous + // scheduled state write. This is needed since the `_lastSaveTime` in + // runDelayed() should be set at least once, or the `_isIdle` flag will not + // become effective. + info("Waiting for sessionstore-state-write-complete notification"); + await TestUtils.topicObserved("sessionstore-state-write-complete"); + + info( + "Got the sessionstore-state-write-complete notification, now testing idle mode" + ); + + // Enter the "idle mode" (raise the `_isIdle` flag) by firing idle + // observer of mock idle service. + idleService._fireObservers("idle"); + + // Cancel any possible state save, which is not related with this test to + // avoid interference. + SessionSaver.cancel(); + + let p1 = promiseSaveState(); + + // Schedule a state write, which is expeced to be postponed after about + // `browser.sessionstore.interval.idle` ms, since the idle flag was just set. + SessionSaver.runDelayed(0); + + // We expect `p1` hits the timeout. + await Assert.rejects( + p1, + /Save state timeout/, + "[Test 1A] No state write during idle." + ); + + // Test again for better reliability. Same, we expect following promise hits + // the timeout. + await Assert.rejects( + promiseSaveState(), + /Save state timeout/, + "[Test 1B] Again: No state write during idle." + ); + + // Back to the active mode. + info("Start to test active mode..."); + idleService._fireObservers("active"); + + info("[Test 2] Waiting for sessionstore-state-write-complete during active"); + await TestUtils.topicObserved("sessionstore-state-write-complete"); +}); diff --git a/browser/components/sessionstore/test/browser_old_favicon.js b/browser/components/sessionstore/test/browser_old_favicon.js new file mode 100644 index 0000000000..fc416e81f6 --- /dev/null +++ b/browser/components/sessionstore/test/browser_old_favicon.js @@ -0,0 +1,57 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Ensure that we can restore old style favicon and principals. + */ +add_task(async function test_label_and_icon() { + // Make sure that tabs are restored on demand as otherwise the tab will start + // loading immediately and override the icon. + await SpecialPowers.pushPrefEnv({ + set: [["browser.sessionstore.restore_on_demand", true]], + }); + + // Create a new tab. + let tab = BrowserTestUtils.addTab( + gBrowser, + "http://www.example.com/browser/browser/components/sessionstore/test/empty.html" + ); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + + let contentPrincipal = browser.contentPrincipal; + let serializedPrincipal = E10SUtils.serializePrincipal(contentPrincipal); + + // Retrieve the tab state. + await TabStateFlusher.flush(browser); + let state = JSON.parse(ss.getTabState(tab)); + state.image = "http://www.example.com/favicon.ico"; + state.iconLoadingPrincipal = serializedPrincipal; + + BrowserTestUtils.removeTab(tab); + + // Open a new tab to restore into. + tab = BrowserTestUtils.addTab(gBrowser, "about:blank"); + ss.setTabState(tab, state); + await promiseTabRestoring(tab); + + // Check that label and icon are set for the restoring tab. + is( + gBrowser.getIcon(tab), + "http://www.example.com/favicon.ico", + "icon is set" + ); + is( + tab.getAttribute("image"), + "http://www.example.com/favicon.ico", + "tab image is set" + ); + is( + tab.getAttribute("iconloadingprincipal"), + serializedPrincipal, + "tab image loading principal is set" + ); + + // Cleanup. + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/sessionstore/test/browser_page_title.js b/browser/components/sessionstore/test/browser_page_title.js new file mode 100644 index 0000000000..9f84e67a94 --- /dev/null +++ b/browser/components/sessionstore/test/browser_page_title.js @@ -0,0 +1,54 @@ +"use strict"; + +const URL = "data:text/html,initial title"; + +add_task(async function () { + // Create a new tab. + let tab = BrowserTestUtils.addTab(gBrowser, URL); + await promiseBrowserLoaded(tab.linkedBrowser); + + // Remove the tab. + await promiseRemoveTabAndSessionState(tab); + + // Check the title. + let [ + { + state: { entries }, + }, + ] = ss.getClosedTabDataForWindow(window); + is(entries[0].title, "initial title", "correct title"); +}); + +add_task(async function () { + // Create a new tab. + let tab = BrowserTestUtils.addTab(gBrowser, URL); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + + // Flush to ensure we collected the initial title. + await TabStateFlusher.flush(browser); + + // Set a new title. + await SpecialPowers.spawn(browser, [], async function () { + return new Promise(resolve => { + docShell.chromeEventHandler.addEventListener( + "DOMTitleChanged", + () => resolve(), + { once: true } + ); + + content.document.title = "new title"; + }); + }); + + // Remove the tab. + await promiseRemoveTabAndSessionState(tab); + + // Check the title. + let [ + { + state: { entries }, + }, + ] = ss.getClosedTabDataForWindow(window); + is(entries[0].title, "new title", "correct title"); +}); diff --git a/browser/components/sessionstore/test/browser_parentProcessRestoreHash.js b/browser/components/sessionstore/test/browser_parentProcessRestoreHash.js new file mode 100644 index 0000000000..442914d580 --- /dev/null +++ b/browser/components/sessionstore/test/browser_parentProcessRestoreHash.js @@ -0,0 +1,115 @@ +"use strict"; + +const SELFCHROMEURL = + "chrome://mochitests/content/browser/browser/" + + "components/sessionstore/test/browser_parentProcessRestoreHash.js"; + +const Cm = Components.manager; + +const TESTCLASSID = "78742c04-3630-448c-9be3-6c5070f062de"; + +const TESTURL = "about:testpageforsessionrestore#foo"; + +let TestAboutPage = { + QueryInterface: ChromeUtils.generateQI(["nsIAboutModule"]), + getURIFlags(aURI) { + // No CAN_ or MUST_LOAD_IN_CHILD means this loads in the parent: + return ( + Ci.nsIAboutModule.ALLOW_SCRIPT | + Ci.nsIAboutModule.URI_SAFE_FOR_UNTRUSTED_CONTENT | + Ci.nsIAboutModule.HIDE_FROM_ABOUTABOUT + ); + }, + + newChannel(aURI, aLoadInfo) { + // about: page inception! + let newURI = Services.io.newURI(SELFCHROMEURL); + let channel = Services.io.newChannelFromURIWithLoadInfo(newURI, aLoadInfo); + channel.originalURI = aURI; + return channel; + }, + + createInstance(iid) { + return this.QueryInterface(iid); + }, + + register() { + Cm.QueryInterface(Ci.nsIComponentRegistrar).registerFactory( + Components.ID(TESTCLASSID), + "Only here for a test", + "@mozilla.org/network/protocol/about;1?what=testpageforsessionrestore", + this + ); + }, + + unregister() { + Cm.QueryInterface(Ci.nsIComponentRegistrar).unregisterFactory( + Components.ID(TESTCLASSID), + this + ); + }, +}; + +/** + * Test that switching from a remote to a parent process browser + * correctly clears the userTypedValue + */ +add_task(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["dom.security.skip_about_page_has_csp_assert", true]], + }); + + TestAboutPage.register(); + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.com/", + true, + true + ); + ok(tab.linkedBrowser.isRemoteBrowser, "Browser should be remote"); + + let resolveLocationChangePromise; + let locationChangePromise = new Promise( + r => (resolveLocationChangePromise = r) + ); + let wpl = { + onStateChange(listener, request, state, status) { + let location = request.QueryInterface(Ci.nsIChannel).originalURI; + // Ignore about:blank loads. + let docStop = + Ci.nsIWebProgressListener.STATE_STOP | + Ci.nsIWebProgressListener.STATE_IS_NETWORK; + if (location.spec == "about:blank" || (state & docStop) != docStop) { + return; + } + is(location.spec, TESTURL, "Got the expected URL"); + resolveLocationChangePromise(); + }, + }; + gBrowser.addProgressListener(wpl); + + gURLBar.value = TESTURL; + gURLBar.select(); + EventUtils.sendKey("return"); + + await locationChangePromise; + + ok(!tab.linkedBrowser.isRemoteBrowser, "Browser should no longer be remote"); + + is(gURLBar.value, TESTURL, "URL bar visible value should be correct."); + is(gURLBar.untrimmedValue, TESTURL, "URL bar value should be correct."); + is( + gURLBar.getAttribute("pageproxystate"), + "valid", + "URL bar is in valid page proxy state" + ); + + ok( + !tab.linkedBrowser.userTypedValue, + "No userTypedValue should be on the browser." + ); + + BrowserTestUtils.removeTab(tab); + gBrowser.removeProgressListener(wpl); + TestAboutPage.unregister(); +}); diff --git a/browser/components/sessionstore/test/browser_pending_tabs.js b/browser/components/sessionstore/test/browser_pending_tabs.js new file mode 100644 index 0000000000..279d2efcf9 --- /dev/null +++ b/browser/components/sessionstore/test/browser_pending_tabs.js @@ -0,0 +1,38 @@ +"use strict"; + +const TAB_STATE = { + entries: [ + { url: "about:mozilla", triggeringPrincipal_base64 }, + { url: "about:robots", triggeringPrincipal_base64 }, + ], + index: 1, +}; + +add_task(async function () { + // Create a background tab. + let tab = BrowserTestUtils.addTab(gBrowser, "about:blank"); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + + // The tab shouldn't be restored right away. + Services.prefs.setBoolPref("browser.sessionstore.restore_on_demand", true); + + // Prepare the tab state. + let promise = promiseTabRestoring(tab); + ss.setTabState(tab, JSON.stringify(TAB_STATE)); + ok(tab.hasAttribute("pending"), "tab is pending"); + await promise; + + // Flush to ensure the parent has all data. + await TabStateFlusher.flush(browser); + + // Check that the shistory index is the one we restored. + let tabState = TabState.collect(tab, ss.getInternalObjectState(tab)); + is(tabState.index, TAB_STATE.index, "correct shistory index"); + + // Check we don't collect userTypedValue when we shouldn't. + ok(!tabState.userTypedValue, "tab didn't have a userTypedValue"); + + // Cleanup. + gBrowser.removeTab(tab); +}); diff --git a/browser/components/sessionstore/test/browser_pinned_tabs.js b/browser/components/sessionstore/test/browser_pinned_tabs.js new file mode 100644 index 0000000000..7a51da7ccc --- /dev/null +++ b/browser/components/sessionstore/test/browser_pinned_tabs.js @@ -0,0 +1,324 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const REMOTE_URL = "https://www.example.com/"; +const ABOUT_ROBOTS_URL = "about:robots"; +const NO_TITLE_URL = "data:text/plain,foo"; + +const BACKUP_STATE = SessionStore.getBrowserState(); +registerCleanupFunction(() => promiseBrowserState(BACKUP_STATE)); + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.sessionstore.restore_on_demand", true], + ["browser.sessionstore.restore_tabs_lazily", true], + ], + }); +}); + +/** + * When implementing batch insertion of tabs as part of session restore, + * we started reversing the insertion order of pinned tabs (bug 1607441). + * This test checks we don't regress that again. + */ +add_task(async function test_pinned_tabs_order() { + // we expect 3 pinned tabs plus the selected tab get content restored. + let allTabsRestored = promiseSessionStoreLoads(4); + await promiseBrowserState({ + windows: [ + { + selected: 4, // SessionStore uses 1-based indexing. + tabs: [ + { + pinned: true, + entries: [{ url: REMOTE_URL, triggeringPrincipal_base64 }], + }, + { + pinned: true, + entries: [{ url: ABOUT_ROBOTS_URL, triggeringPrincipal_base64 }], + }, + { + pinned: true, + entries: [{ url: NO_TITLE_URL, triggeringPrincipal_base64 }], + }, + { entries: [{ url: REMOTE_URL, triggeringPrincipal_base64 }] }, + { entries: [{ url: "about:blank", triggeringPrincipal_base64 }] }, + ], + }, + ], + }); + await allTabsRestored; + let [tab1, tab2, tab3, tab4, tab5] = gBrowser.tabs; + ok(tab1.pinned, "First tab is pinned"); + ok(tab2.pinned, "Second tab is pinned"); + ok(tab3.pinned, "Third tab is pinned"); + ok(!tab4.pinned, "Fourth tab is not pinned"); + ok(!tab5.pinned, "Fifth tab is not pinned"); + + ok(tab4.selected, "Fourth tab is selected"); + is( + tab1.linkedBrowser.currentURI.spec, + REMOTE_URL, + "First tab has matching URL" + ); + is( + tab2.linkedBrowser.currentURI.spec, + ABOUT_ROBOTS_URL, + "Second tab has matching URL" + ); + is( + tab3.linkedBrowser.currentURI.spec, + NO_TITLE_URL, + "Third tab has matching URL" + ); + // Clean up for the next task. + await promiseBrowserState(BACKUP_STATE); +}); + +/** + * When fixing the previous regression, pinned tabs started disappearing out + * of sessions with selected pinned tabs. This test checks that case. + */ +add_task(async function test_selected_pinned_tab_dataloss() { + // we expect 3 pinned tabs (one of which is selected) get content restored. + let allTabsRestored = promiseSessionStoreLoads(3); + await promiseBrowserState({ + windows: [ + { + selected: 1, // SessionStore uses 1-based indexing. + tabs: [ + { + pinned: true, + entries: [{ url: REMOTE_URL, triggeringPrincipal_base64 }], + }, + { + pinned: true, + entries: [{ url: ABOUT_ROBOTS_URL, triggeringPrincipal_base64 }], + }, + { + pinned: true, + entries: [{ url: NO_TITLE_URL, triggeringPrincipal_base64 }], + }, + { entries: [{ url: REMOTE_URL, triggeringPrincipal_base64 }] }, + { entries: [{ url: "about:blank", triggeringPrincipal_base64 }] }, + ], + }, + ], + }); + await allTabsRestored; + let [tab1, tab2, tab3, tab4, tab5] = gBrowser.tabs; + ok(tab5, "Should have 5 tabs"); + ok(tab1.pinned, "First tab is pinned"); + ok(tab2.pinned, "Second tab is pinned"); + ok(tab3.pinned, "Third tab is pinned"); + ok(tab4 && !tab4.pinned, "Fourth tab is not pinned"); + ok(tab5 && !tab5.pinned, "Fifth tab is not pinned"); + + ok(tab1 && tab1.selected, "First (pinned) tab is selected"); + is( + tab1.linkedBrowser.currentURI.spec, + REMOTE_URL, + "First tab has matching URL" + ); + is( + tab2.linkedBrowser.currentURI.spec, + ABOUT_ROBOTS_URL, + "Second tab has matching URL" + ); + is( + tab3.linkedBrowser.currentURI.spec, + NO_TITLE_URL, + "Third tab has matching URL" + ); + // Clean up for the next task. + await promiseBrowserState(BACKUP_STATE); +}); + +/** + * While we're here, it seems useful to have a test for mixed pinned and + * unpinned tabs in session store state, as well as userContextId. + */ +add_task(async function test_mixed_pinned_unpinned() { + // we expect 3 pinned tabs plus the selected tab get content restored. + let allTabsRestored = promiseSessionStoreLoads(4); + await promiseBrowserState({ + windows: [ + { + selected: 4, // SessionStore uses 1-based indexing. + tabs: [ + { + pinned: true, + entries: [{ url: REMOTE_URL, triggeringPrincipal_base64 }], + }, + { entries: [{ url: REMOTE_URL, triggeringPrincipal_base64 }] }, + { + pinned: true, + entries: [{ url: ABOUT_ROBOTS_URL, triggeringPrincipal_base64 }], + }, + { entries: [{ url: "about:blank", triggeringPrincipal_base64 }] }, + { + pinned: true, + entries: [{ url: NO_TITLE_URL, triggeringPrincipal_base64 }], + }, + ], + }, + ], + }); + await allTabsRestored; + let [tab1, tab2, tab3, tab4, tab5] = gBrowser.tabs; + ok(tab1.pinned, "First tab is pinned"); + ok(tab2.pinned, "Second tab is pinned"); + ok(tab3.pinned, "Third tab is pinned"); + ok(!tab4.pinned, "Fourth tab is not pinned"); + ok(!tab5.pinned, "Fifth tab is not pinned"); + + // This is confusing to read - the 4th entry in the session data is + // selected. But the 5th entry is pinned, so it moves to the start of the + // tabstrip, so when we fetch `gBrowser.tabs`, the 4th entry in the list + // is actually the 5th tab. + ok(tab5.selected, "Fifth tab is selected"); + is( + tab1.linkedBrowser.currentURI.spec, + REMOTE_URL, + "First tab has matching URL" + ); + is( + tab2.linkedBrowser.currentURI.spec, + ABOUT_ROBOTS_URL, + "Second tab has matching URL" + ); + is( + tab3.linkedBrowser.currentURI.spec, + NO_TITLE_URL, + "Third tab has matching URL" + ); + // Clean up for the next task. + await promiseBrowserState(BACKUP_STATE); +}); + +/** + * After session restore, if we crash an unpinned tab, we noticed pinned tabs + * created in the same process would lose all data (Bug 1624511). This test + * checks that case. + */ +add_task(async function test_pinned_tab_dataloss() { + // We do not run if there are no crash reporters to avoid + // problems with the intentional crash. + if (!AppConstants.MOZ_CRASHREPORTER) { + return; + } + // If we end up increasing the process count limit in future, + // we want to ensure that we don't stop testing this case + // of pinned tab data loss. + if (SpecialPowers.getIntPref("dom.ipc.processCount") > 8) { + ok( + false, + "Process count is greater than 8, update the number of pinned tabs in test." + ); + } + + // We expect 17 pinned tabs plus the selected tab get content restored. + // Given that the default process count is currently 8, we need this + // number of pinned tabs to reproduce the data loss. If this changes, + // please add more pinned tabs. + let allTabsRestored = promiseSessionStoreLoads(18); + await promiseBrowserState({ + windows: [ + { + selected: 18, // SessionStore uses 1-based indexing. + tabs: [ + { + pinned: true, + entries: [{ url: REMOTE_URL, triggeringPrincipal_base64 }], + }, + { + pinned: true, + entries: [{ url: REMOTE_URL, triggeringPrincipal_base64 }], + }, + { + pinned: true, + entries: [{ url: REMOTE_URL, triggeringPrincipal_base64 }], + }, + { + pinned: true, + entries: [{ url: REMOTE_URL, triggeringPrincipal_base64 }], + }, + { + pinned: true, + entries: [{ url: REMOTE_URL, triggeringPrincipal_base64 }], + }, + { + pinned: true, + entries: [{ url: REMOTE_URL, triggeringPrincipal_base64 }], + }, + { + pinned: true, + entries: [{ url: REMOTE_URL, triggeringPrincipal_base64 }], + }, + { + pinned: true, + entries: [{ url: REMOTE_URL, triggeringPrincipal_base64 }], + }, + { + pinned: true, + entries: [{ url: REMOTE_URL, triggeringPrincipal_base64 }], + }, + { + pinned: true, + entries: [{ url: REMOTE_URL, triggeringPrincipal_base64 }], + }, + { + pinned: true, + entries: [{ url: REMOTE_URL, triggeringPrincipal_base64 }], + }, + { + pinned: true, + entries: [{ url: REMOTE_URL, triggeringPrincipal_base64 }], + }, + { + pinned: true, + entries: [{ url: REMOTE_URL, triggeringPrincipal_base64 }], + }, + { + pinned: true, + entries: [{ url: REMOTE_URL, triggeringPrincipal_base64 }], + }, + { + pinned: true, + entries: [{ url: REMOTE_URL, triggeringPrincipal_base64 }], + }, + { + pinned: true, + entries: [{ url: REMOTE_URL, triggeringPrincipal_base64 }], + }, + { + pinned: true, + entries: [{ url: REMOTE_URL, triggeringPrincipal_base64 }], + }, + { entries: [{ url: REMOTE_URL, triggeringPrincipal_base64 }] }, + ], + }, + ], + }); + await allTabsRestored; + + let tabs = gBrowser.tabs; + await BrowserTestUtils.crashFrame(tabs[17].linkedBrowser); + + await TestUtils.topicObserved("sessionstore-state-write-complete"); + + for (let i = 0; i < tabs.length; i++) { + let tab = tabs[i]; + is( + tab.linkedBrowser.currentURI.spec, + REMOTE_URL, + `Tab ${i + 1} should have matching URL` + ); + } + + // Clean up for the next task. + await promiseBrowserState(BACKUP_STATE); +}); diff --git a/browser/components/sessionstore/test/browser_privatetabs.js b/browser/components/sessionstore/test/browser_privatetabs.js new file mode 100644 index 0000000000..73529c0b64 --- /dev/null +++ b/browser/components/sessionstore/test/browser_privatetabs.js @@ -0,0 +1,33 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_task(function cleanup() { + info("Forgetting closed tabs"); + forgetClosedTabs(window); +}); + +add_task(async function () { + // Clear the list of closed windows. + forgetClosedWindows(); + + // Create a new window to attach our frame script to. + let win = await promiseNewWindowLoaded({ private: true }); + + // Create a new tab in the new window that will load the frame script. + let tab = BrowserTestUtils.addTab(win.gBrowser, "about:mozilla"); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + await TabStateFlusher.flush(browser); + + // Check that we consider the tab as private. + let state = JSON.parse(ss.getTabState(tab)); + ok(state.isPrivate, "tab considered private"); + + // Ensure that closed tabs in a private windows can be restored. + win.gBrowser.removeTab(tab); + is(ss.getClosedTabCountForWindow(win), 1, "there is a single tab to restore"); + + // Ensure that closed private windows can never be restored. + await BrowserTestUtils.closeWindow(win); + is(ss.getClosedWindowCount(), 0, "no windows to restore"); +}); diff --git a/browser/components/sessionstore/test/browser_purge_shistory.js b/browser/components/sessionstore/test/browser_purge_shistory.js new file mode 100644 index 0000000000..2078bb46ed --- /dev/null +++ b/browser/components/sessionstore/test/browser_purge_shistory.js @@ -0,0 +1,65 @@ +"use strict"; + +/** + * This test checks that pending tabs are treated like fully loaded tabs when + * purging session history. Just like for fully loaded tabs we want to remove + * every but the current shistory entry. + */ + +const TAB_STATE = { + entries: [ + { url: "about:mozilla", triggeringPrincipal_base64 }, + { url: "about:robots", triggeringPrincipal_base64 }, + ], + index: 1, +}; + +function checkTabContents(browser) { + return SpecialPowers.spawn(browser, [], async function () { + let webNavigation = docShell.QueryInterface(Ci.nsIWebNavigation); + let history = webNavigation.sessionHistory; + Assert.ok( + history && + history.count == 1 && + content.document.documentURI == "about:mozilla", + "expected tab contents found" + ); + }); +} + +add_task(async function () { + // Create a new tab. + let tab = BrowserTestUtils.addTab(gBrowser, "about:blank"); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + await promiseTabState(tab, TAB_STATE); + + // Create another new tab. + let tab2 = BrowserTestUtils.addTab(gBrowser, "about:blank"); + let browser2 = tab2.linkedBrowser; + await promiseBrowserLoaded(browser2); + + // The tab shouldn't be restored right away. + Services.prefs.setBoolPref("browser.sessionstore.restore_on_demand", true); + + // Prepare the tab state. + let promise = promiseTabRestoring(tab2); + ss.setTabState(tab2, JSON.stringify(TAB_STATE)); + ok(tab2.hasAttribute("pending"), "tab is pending"); + await promise; + + // Purge session history. + Services.obs.notifyObservers(null, "browser:purge-session-history"); + await checkTabContents(browser); + ok(tab2.hasAttribute("pending"), "tab is still pending"); + + // Kick off tab restoration. + gBrowser.selectedTab = tab2; + await promiseTabRestored(tab2); + await checkTabContents(browser2); + ok(!tab2.hasAttribute("pending"), "tab is not pending anymore"); + + // Cleanup. + gBrowser.removeTab(tab2); + gBrowser.removeTab(tab); +}); diff --git a/browser/components/sessionstore/test/browser_remoteness_flip_on_restore.js b/browser/components/sessionstore/test/browser_remoteness_flip_on_restore.js new file mode 100644 index 0000000000..8674664ede --- /dev/null +++ b/browser/components/sessionstore/test/browser_remoteness_flip_on_restore.js @@ -0,0 +1,310 @@ +"use strict"; + +/** + * This set of tests checks that the remoteness is properly + * set for each browser in a window when that window has + * session state loaded into it. + */ + +/** + * Takes a SessionStore window state object for a single + * window, sets the selected tab for it, and then returns + * the object to be passed to SessionStore.setWindowState. + * + * @param state (object) + * The state to prepare to be sent to a window. This is + * state should just be for a single window. + * @param selected (int) + * The 1-based index of the selected tab. Note that + * If this is 0, then the selected tab will not change + * from what's already selected in the window that we're + * sending state to. + * @returns (object) + * The JSON encoded string to call + * SessionStore.setWindowState with. + */ +function prepareState(state, selected) { + // We'll create a copy so that we don't accidentally + // modify the caller's selected property. + let copy = {}; + Object.assign(copy, state); + copy.selected = selected; + + return { + windows: [copy], + }; +} + +const SIMPLE_STATE = { + tabs: [ + { + entries: [ + { + url: "http://example.com/", + triggeringPrincipal_base64, + title: "title", + }, + ], + }, + { + entries: [ + { + url: "http://example.com/", + triggeringPrincipal_base64, + title: "title", + }, + ], + }, + { + entries: [ + { + url: "http://example.com/", + triggeringPrincipal_base64, + title: "title", + }, + ], + }, + ], + title: "", + _closedTabs: [], +}; + +const PINNED_STATE = { + tabs: [ + { + entries: [ + { + url: "http://example.com/", + triggeringPrincipal_base64, + title: "title", + }, + ], + pinned: true, + }, + { + entries: [ + { + url: "http://example.com/", + triggeringPrincipal_base64, + title: "title", + }, + ], + pinned: true, + }, + { + entries: [ + { + url: "http://example.com/", + triggeringPrincipal_base64, + title: "title", + }, + ], + }, + ], + title: "", + _closedTabs: [], +}; + +/** + * This is where most of the action is happening. This function takes + * an Array of "test scenario" Objects and runs them. For each scenario, a + * window is opened, put into some state, and then a new state is + * loaded into that window. We then check to make sure that the + * right things have happened in that window wrt remoteness flips. + * + * The schema for a testing scenario Object is as follows: + * + * initialRemoteness: + * an Array that represents the starting window. Each bool + * in the Array represents the window tabs in order. A "true" + * indicates that that tab should be remote. "false" if the tab + * should be non-remote. + * + * initialSelectedTab: + * The 1-based index of the tab that we want to select for the + * restored window. This is 1-based to avoid confusion with the + * selectedTab property described down below, though you probably + * want to set this to be greater than 0, since the initial window + * needs to have a defined initial selected tab. Because of this, + * the test will throw if initialSelectedTab is 0. + * + * stateToRestore: + * A JS Object for the state to send down to the window. + * + * selectedTab: + * The 1-based index of the tab that we want to select for the + * restored window. Leave this at 0 if you don't want to change + * the selection from the initial window state. + * + * expectedRemoteness: + * an Array that represents the window that we end up with after + * restoring state. Each bool in the Array represents the window + * tabs in order. A "true" indicates that the tab be remote, and + * a "false" indicates that the tab should be "non-remote". We + * need this Array in order to test pinned tabs which will also + * be loaded by default, and therefore should end up remote. + * + */ +async function runScenarios(scenarios) { + for (let [scenarioIndex, scenario] of scenarios.entries()) { + info("Running scenario " + scenarioIndex); + Assert.ok( + scenario.initialSelectedTab > 0, + "You must define an initially selected tab" + ); + + // First, we need to create the initial conditions, so we + // open a new window to put into our starting state... + let win = await BrowserTestUtils.openNewBrowserWindow(); + let tabbrowser = win.gBrowser; + Assert.ok( + tabbrowser.selectedBrowser.isRemoteBrowser, + "The initial browser should be remote." + ); + // Now put the window into the expected initial state. + for (let i = 0; i < scenario.initialRemoteness.length; ++i) { + let tab; + if (i > 0) { + // The window starts with one tab, so we need to create + // any of the additional ones required by this test. + info("Opening a new tab"); + tab = await BrowserTestUtils.openNewForegroundTab(tabbrowser); + } else { + info("Using the selected tab"); + tab = tabbrowser.selectedTab; + } + let browser = tab.linkedBrowser; + let remotenessState = scenario.initialRemoteness[i] + ? E10SUtils.DEFAULT_REMOTE_TYPE + : E10SUtils.NOT_REMOTE; + tabbrowser.updateBrowserRemoteness(browser, { + remoteType: remotenessState, + }); + } + + // And select the requested tab. + let tabToSelect = tabbrowser.tabs[scenario.initialSelectedTab - 1]; + if (tabbrowser.selectedTab != tabToSelect) { + await BrowserTestUtils.switchTab(tabbrowser, tabToSelect); + } + + // Okay, time to test! + let state = prepareState(scenario.stateToRestore, scenario.selectedTab); + + await setWindowState(win, state, true); + + for (let i = 0; i < scenario.expectedRemoteness.length; ++i) { + let expectedRemoteness = scenario.expectedRemoteness[i]; + let tab = tabbrowser.tabs[i]; + + Assert.equal( + tab.linkedBrowser.isRemoteBrowser, + expectedRemoteness, + "Should have gotten the expected remoteness " + + `for the tab at index ${i}` + ); + } + + await BrowserTestUtils.closeWindow(win); + } +} + +/** + * Tests that if we restore state to browser windows with + * a variety of initial remoteness states. For this particular + * set of tests, we assume that tabs are restoring on demand. + */ +add_task(async function () { + // This test opens and closes windows, which might bog down + // a debug build long enough to time out the test, so we + // extend the tolerance on timeouts. + requestLongerTimeout(5); + + await SpecialPowers.pushPrefEnv({ + set: [["browser.sessionstore.restore_on_demand", true]], + }); + + const TEST_SCENARIOS = [ + // Only one tab in the new window, and it's remote. This + // is the common case, since this is how restoration occurs + // when the restored window is being opened. + { + initialRemoteness: [true], + initialSelectedTab: 1, + stateToRestore: SIMPLE_STATE, + selectedTab: 3, + // All tabs should now be remote. + expectedRemoteness: [true, true, true], + }, + + // A single remote tab, and this is the one that's going + // to be selected once state is restored. + { + initialRemoteness: [true], + initialSelectedTab: 1, + stateToRestore: SIMPLE_STATE, + selectedTab: 1, + // All tabs should now be remote. + expectedRemoteness: [true, true, true], + }, + + // A single remote tab which starts selected. We set the + // selectedTab to 0 which is equivalent to "don't change + // the tab selection in the window". + { + initialRemoteness: [true], + initialSelectedTab: 1, + stateToRestore: SIMPLE_STATE, + selectedTab: 0, + // All tabs should now be remote. + expectedRemoteness: [true, true, true], + }, + + // An initially remote tab, but we're going to load + // some pinned tabs now, and the pinned tabs should load + // right away. + { + initialRemoteness: [true], + initialSelectedTab: 1, + stateToRestore: PINNED_STATE, + selectedTab: 3, + // Both pinned tabs and the selected tabs should all + // end up being remote. + expectedRemoteness: [true, true, true], + }, + + // A single non-remote tab. + { + initialRemoteness: [false], + initialSelectedTab: 1, + stateToRestore: SIMPLE_STATE, + selectedTab: 2, + // All tabs should now be remote. + expectedRemoteness: [true, true, true], + }, + + // A mixture of remote and non-remote tabs. + { + initialRemoteness: [true, false, true], + initialSelectedTab: 1, + stateToRestore: SIMPLE_STATE, + selectedTab: 3, + // All tabs should now be remote. + expectedRemoteness: [true, true, true], + }, + + // An initially non-remote tab, but we're going to load + // some pinned tabs now, and the pinned tabs should load + // right away. + { + initialRemoteness: [false], + initialSelectedTab: 1, + stateToRestore: PINNED_STATE, + selectedTab: 3, + // All tabs should now be remote. + expectedRemoteness: [true, true, true], + }, + ]; + + await runScenarios(TEST_SCENARIOS); +}); diff --git a/browser/components/sessionstore/test/browser_reopen_all_windows.js b/browser/components/sessionstore/test/browser_reopen_all_windows.js new file mode 100644 index 0000000000..532e689f50 --- /dev/null +++ b/browser/components/sessionstore/test/browser_reopen_all_windows.js @@ -0,0 +1,146 @@ +"use strict"; + +const PATH = "browser/browser/components/sessionstore/test/empty.html"; +var URLS_WIN1 = [ + "https://example.com/" + PATH, + "https://example.org/" + PATH, + "http://test1.mochi.test:8888/" + PATH, + "http://test1.example.com/" + PATH, +]; +var EXPECTED_URLS_WIN1 = ["about:blank", ...URLS_WIN1]; + +var URLS_WIN2 = [ + "http://sub1.test1.mochi.test:8888/" + PATH, + "http://sub2.xn--lt-uia.mochi.test:8888/" + PATH, + "http://test2.mochi.test:8888/" + PATH, + "http://sub1.test2.example.org/" + PATH, + "http://sub2.test1.example.org/" + PATH, + "http://test2.example.com/" + PATH, +]; +var EXPECTED_URLS_WIN2 = ["about:blank", ...URLS_WIN2]; + +requestLongerTimeout(4); + +function allTabsRestored(win, expectedUrls) { + return new Promise(resolve => { + let tabsRestored = 0; + function handler(event) { + let spec = event.target.linkedBrowser.currentURI.spec; + if (expectedUrls.includes(spec)) { + tabsRestored++; + } + info(`Got SSTabRestored for ${spec}, tabsRestored=${tabsRestored}`); + if (tabsRestored === expectedUrls.length) { + win.gBrowser.tabContainer.removeEventListener( + "SSTabRestored", + handler, + true + ); + resolve(); + } + } + win.gBrowser.tabContainer.addEventListener("SSTabRestored", handler, true); + }); +} + +async function windowAndTabsRestored(win, expectedUrls) { + await TestUtils.topicObserved( + "browser-window-before-show", + subject => subject === win + ); + return allTabsRestored(win, expectedUrls); +} + +add_task(async () => { + await SpecialPowers.pushPrefEnv({ + set: [ + // Set the pref to true so we know exactly how many tabs should be restoring at + // any given time. This guarantees that a finishing load won't start another. + ["browser.sessionstore.restore_on_demand", false], + ], + }); + + forgetClosedWindows(); + is(SessionStore.getClosedWindowCount(), 0, "starting with no closed windows"); + + // Open window 1, with different tabs + let win1 = await BrowserTestUtils.openNewBrowserWindow(); + for (let url of URLS_WIN1) { + await BrowserTestUtils.openNewForegroundTab(win1.gBrowser, url); + } + + // Open window 2, with different tabs + let win2 = await BrowserTestUtils.openNewBrowserWindow(); + for (let url of URLS_WIN2) { + await BrowserTestUtils.openNewForegroundTab(win2.gBrowser, url); + } + + await TabStateFlusher.flushWindow(win1); + await TabStateFlusher.flushWindow(win2); + + // Close both windows + await BrowserTestUtils.closeWindow(win1); + await BrowserTestUtils.closeWindow(win2); + await forceSaveState(); + + // Verify both windows were accounted for by session store + is( + ss.getClosedWindowCount(), + 2, + "The closed windows was added to Recently Closed Windows" + ); + + // We previously used to manually navigate the Library menu to click the + // "Reopen all Windows" button, but that reopens all windows at once without + // returning a reference to each window. Since we need to attach listeners to + // these windows *before* they start restoring tabs, we now manually call + // undoCloseWindow() here, which has the same effect, but also gives us the + // window references. + info("Reopening windows"); + let restoredWindows = []; + while (SessionStore.getClosedWindowCount() > 0) { + restoredWindows.unshift(undoCloseWindow()); + } + is(restoredWindows.length, 2, "Reopened correct number of windows"); + + let win1Restored = windowAndTabsRestored( + restoredWindows[0], + EXPECTED_URLS_WIN1 + ); + let win2Restored = windowAndTabsRestored( + restoredWindows[1], + EXPECTED_URLS_WIN2 + ); + + info("About to wait for tabs to be restored"); + await Promise.all([win1Restored, win2Restored]); + + is( + restoredWindows[0].gBrowser.tabs.length, + EXPECTED_URLS_WIN1.length, + "All tabs restored" + ); + is( + restoredWindows[1].gBrowser.tabs.length, + EXPECTED_URLS_WIN2.length, + "All tabs restored" + ); + + // Verify that tabs opened as expected + Assert.deepEqual( + restoredWindows[0].gBrowser.tabs.map( + tab => tab.linkedBrowser.currentURI.spec + ), + EXPECTED_URLS_WIN1 + ); + Assert.deepEqual( + restoredWindows[1].gBrowser.tabs.map( + tab => tab.linkedBrowser.currentURI.spec + ), + EXPECTED_URLS_WIN2 + ); + + info("About to close windows"); + await BrowserTestUtils.closeWindow(restoredWindows[0]); + await BrowserTestUtils.closeWindow(restoredWindows[1]); +}); diff --git a/browser/components/sessionstore/test/browser_replace_load.js b/browser/components/sessionstore/test/browser_replace_load.js new file mode 100644 index 0000000000..21bec044a9 --- /dev/null +++ b/browser/components/sessionstore/test/browser_replace_load.js @@ -0,0 +1,56 @@ +"use strict"; + +const STATE = { + entries: [{ url: "about:robots" }, { url: "about:mozilla" }], + selected: 2, +}; + +/** + * Bug 1100223. Calling browser.loadURI() while a tab is loading causes + * sessionstore to override the desired target URL. This test ensures that + * calling loadURI() on a pending tab causes the tab to no longer be marked + * as pending and correctly finish the instructed load while keeping the + * restored history around. + */ +add_task(async function () { + await testSwitchToTab("about:mozilla#fooobar", { + ignoreFragment: "whenComparingAndReplace", + }); + await testSwitchToTab("about:mozilla?foo=bar", { replaceQueryString: true }); +}); + +var testSwitchToTab = async function (url, options) { + // Create a background tab. + let tab = BrowserTestUtils.addTab(gBrowser, "about:blank"); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + + // The tab shouldn't be restored right away. + Services.prefs.setBoolPref("browser.sessionstore.restore_on_demand", true); + + // Prepare the tab state. + let promise = promiseTabRestoring(tab); + ss.setTabState(tab, JSON.stringify(STATE)); + ok(tab.hasAttribute("pending"), "tab is pending"); + await promise; + + options.triggeringPrincipal = + Services.scriptSecurityManager.getSystemPrincipal(); + + // Switch-to-tab with a similar URI. + switchToTabHavingURI(url, false, options); + + // Tab should now restore + await promiseTabRestored(tab); + is(browser.currentURI.spec, url, "correct URL loaded"); + + // Check that we didn't lose any history entries. + await SpecialPowers.spawn(browser, [], async function () { + let webNavigation = docShell.QueryInterface(Ci.nsIWebNavigation); + let history = webNavigation.sessionHistory; + Assert.equal(history && history.count, 3, "three history entries"); + }); + + // Cleanup. + gBrowser.removeTab(tab); +}; diff --git a/browser/components/sessionstore/test/browser_restoreTabContainer.js b/browser/components/sessionstore/test/browser_restoreTabContainer.js new file mode 100644 index 0000000000..a38dca386e --- /dev/null +++ b/browser/components/sessionstore/test/browser_restoreTabContainer.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/. */ + +"use strict"; + +const TEST_PATH = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.com" +); + +add_task(async function () { + const testUserContextId = 2; + const testCases = [ + { + url: `${TEST_PATH}empty.html`, + crossOriginIsolated: false, + }, + { + url: `${TEST_PATH}coop_coep.html`, + crossOriginIsolated: true, + }, + ]; + + for (const testCase of testCases) { + let tab = BrowserTestUtils.addTab(gBrowser, testCase.url, { + userContextId: testUserContextId, + }); + await promiseBrowserLoaded(tab.linkedBrowser); + + is( + tab.userContextId, + testUserContextId, + `The tab was opened with the expected userContextId` + ); + + await SpecialPowers.spawn( + tab.linkedBrowser, + [testCase.crossOriginIsolated], + async expectedCrossOriginIsolated => { + is( + content.window.crossOriginIsolated, + expectedCrossOriginIsolated, + `The tab was opened in the expected crossOriginIsolated environment` + ); + } + ); + + let sessionPromise = BrowserTestUtils.waitForSessionStoreUpdate(tab); + BrowserTestUtils.removeTab(tab); + await sessionPromise; + + let restoredTab = SessionStore.undoCloseTab(window, 0); + + // TODO: also check that `promiseTabRestored` is fulfilled. This currently + // doesn't happen correctly in some cases, as the content restore is aborted + // when the process switch occurs to load a cross-origin-isolated document + // into a different process. + await promiseBrowserLoaded(restoredTab.linkedBrowser); + + is( + restoredTab.userContextId, + testUserContextId, + `The tab was restored with the expected userContextId` + ); + + await SpecialPowers.spawn( + restoredTab.linkedBrowser, + [testCase.crossOriginIsolated], + async expectedCrossOriginIsolated => { + is( + content.window.crossOriginIsolated, + expectedCrossOriginIsolated, + `The tab was restored in the expected crossOriginIsolated environment` + ); + } + ); + + BrowserTestUtils.removeTab(restoredTab); + } +}); diff --git a/browser/components/sessionstore/test/browser_restore_container_tabs_oa.js b/browser/components/sessionstore/test/browser_restore_container_tabs_oa.js new file mode 100644 index 0000000000..c80a2d710b --- /dev/null +++ b/browser/components/sessionstore/test/browser_restore_container_tabs_oa.js @@ -0,0 +1,231 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const PATH = "browser/browser/components/sessionstore/test/empty.html"; + +/* import-globals-from ../../../base/content/test/tabs/helper_origin_attrs_testing.js */ +loadTestSubscript( + "../../../base/content/test/tabs/helper_origin_attrs_testing.js" +); + +var TEST_CASES = [ + "https://example.com/" + PATH, + "https://example.org/" + PATH, + "about:preferences", + "about:config", +]; + +var remoteTypes; + +var xulFrameLoaderCreatedCounter = {}; + +function handleEventLocal(aEvent) { + if (aEvent.type != "XULFrameLoaderCreated") { + return; + } + // Ignore element in about:preferences and any other special pages + if ("gBrowser" in aEvent.target.ownerGlobal) { + xulFrameLoaderCreatedCounter.numCalledSoFar++; + } +} + +var NUM_DIFF_TAB_MODES = NUM_USER_CONTEXTS + 1; /** regular tab */ + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + // Set the pref to true so we know exactly how many tabs should be restoring at + // any given time. This guarantees that a finishing load won't start another. + ["browser.sessionstore.restore_on_demand", true], + // Don't restore tabs lazily. + ["browser.sessionstore.restore_tabs_lazily", true], + // don't preload tabs so we don't have extra XULFrameLoaderCreated events + // firing + ["browser.newtab.preload", false], + ], + }); + + requestLongerTimeout(7); +}); + +function setupRemoteTypes() { + if (gFissionBrowser) { + remoteTypes = [ + "webIsolated=https://example.com", + "webIsolated=https://example.com^userContextId=1", + "webIsolated=https://example.com^userContextId=2", + "webIsolated=https://example.com^userContextId=3", + "webIsolated=https://example.org", + "webIsolated=https://example.org^userContextId=1", + "webIsolated=https://example.org^userContextId=2", + "webIsolated=https://example.org^userContextId=3", + ]; + } else { + remoteTypes = Array( + NUM_DIFF_TAB_MODES * 2 /** 2 is the number of non parent uris */ + ).fill("web"); + } + remoteTypes.push(...Array(NUM_DIFF_TAB_MODES * 2).fill(null)); // remote types for about: pages + + forgetClosedWindows(); + is(SessionStore.getClosedWindowCount(), 0, "starting with no closed windows"); +} +/* + * 1. Open several tabs in different containers and in regular tabs + [page1, page2, page3] [ [(page1 - work) (page1 - home)] [(page2 - work) (page2 - home)] ] + * 2. Close the window + * 3. Restore session, window will have the following tabs + * [initial blank page, page1, page1-work, page1-home, page2, page2-work, page2-home] + * 4. Verify correct remote types and that XULFrameLoaderCreated gets fired correct number of times + */ +add_task(async function testRestore() { + setupRemoteTypes(); + let newWin = await promiseNewWindowLoaded(); + var regularPages = []; + var containerPages = {}; + // Go through all the test cases and open same set of urls in regular tabs and in container tabs + for (const uri of TEST_CASES) { + // Open a url in a regular tab + let regularPage = await openURIInRegularTab(uri, newWin); + regularPages.push(regularPage); + + // Open the same url in different user contexts + for ( + var user_context_id = 1; + user_context_id <= NUM_USER_CONTEXTS; + user_context_id++ + ) { + let containerPage = await openURIInContainer( + uri, + newWin, + user_context_id + ); + containerPages[uri] = containerPage; + } + } + await TabStateFlusher.flushWindow(newWin); + + // Close the window + await BrowserTestUtils.closeWindow(newWin); + await forceSaveState(); + + is( + SessionStore.getClosedWindowCount(), + 1, + "Should have restore data for the closed window" + ); + + // Now restore the window + newWin = SessionStore.undoCloseWindow(0); + + // Make sure to wait for the window to be restored. + await Promise.all([ + BrowserTestUtils.waitForEvent(newWin, "SSWindowStateReady"), + ]); + await BrowserTestUtils.waitForEvent( + newWin.gBrowser.tabContainer, + "SSTabRestored" + ); + + var nonblank_pages_len = + TEST_CASES.length + NUM_USER_CONTEXTS * TEST_CASES.length; + is( + newWin.gBrowser.tabs.length, + nonblank_pages_len + 1 /* initial page */, + "Correct number of tabs restored" + ); + + // Now we have pages opened in the following manner + // [blank page, page1, page1-work, page1-home, page2, page2-work, page2-home] + + info(`Number of tabs restored: ${newWin.gBrowser.tabs.length}`); + var currRemoteType, expectedRemoteType; + let loaded; + for (var tab_idx = 1; tab_idx < nonblank_pages_len; ) { + info(`Accessing regular tab at index ${tab_idx}`); + var test_page_data = regularPages.shift(); + let regular_tab = newWin.gBrowser.tabs[tab_idx]; + let regular_browser = regular_tab.linkedBrowser; + + // I would have used browserLoaded but for about:config it doesn't work + let ready = BrowserTestUtils.waitForCondition(async () => { + // Catch an error because the browser might change remoteness in between + // calls, so we will just wait for the document to finish loadig. + return SpecialPowers.spawn(regular_browser, [], () => { + return content.document.readyState == "complete"; + }).catch(console.error); + }); + newWin.gBrowser.selectedTab = regular_tab; + await TabStateFlusher.flush(regular_browser); + await ready; + + currRemoteType = regular_browser.remoteType; + expectedRemoteType = remoteTypes.shift(); + is( + currRemoteType, + expectedRemoteType, + `correct remote type for regular tab with uri ${test_page_data.uri}` + ); + + let page_uri = regular_browser.currentURI.spec; + info(`Current uri = ${page_uri}`); + + // Iterate over container pages, starting after the regular page and ending before the next regular page + var userContextId = 1; + for ( + var container_tab_idx = tab_idx + 1; + container_tab_idx < tab_idx + 1 + NUM_USER_CONTEXTS; + container_tab_idx++, userContextId++ + ) { + info(`Accessing container tab at index ${container_tab_idx}`); + let container_tab = newWin.gBrowser.tabs[container_tab_idx]; + + initXulFrameLoaderCreatedCounter(xulFrameLoaderCreatedCounter); + container_tab.ownerGlobal.gBrowser.addEventListener( + "XULFrameLoaderCreated", + handleEventLocal + ); + + loaded = BrowserTestUtils.browserLoaded( + container_tab.linkedBrowser, + false, + test_page_data.uri + ); + + newWin.gBrowser.selectedTab = container_tab; + await TabStateFlusher.flush(container_tab.linkedBrowser); + await loaded; + let uri = container_tab.linkedBrowser.currentURI.spec; + + // Verify XULFrameLoaderCreated was fired once + is( + xulFrameLoaderCreatedCounter.numCalledSoFar, + 1, + `XULFrameLoaderCreated was fired once, when restoring ${uri} in container ${userContextId} ` + ); + container_tab.ownerGlobal.gBrowser.removeEventListener( + "XULFrameLoaderCreated", + handleEventLocal + ); + + // Verify correct remote type for container tab + currRemoteType = container_tab.linkedBrowser.remoteType; + expectedRemoteType = remoteTypes.shift(); + info( + `Remote type for container tab ${userContextId} is ${currRemoteType}` + ); + is( + currRemoteType, + expectedRemoteType, + "correct remote type for container tab" + ); + } + // Advance to the next regular page in our tabs list + tab_idx = container_tab_idx; + } + + await BrowserTestUtils.closeWindow(newWin); +}); diff --git a/browser/components/sessionstore/test/browser_restore_cookies_noOriginAttributes.js b/browser/components/sessionstore/test/browser_restore_cookies_noOriginAttributes.js new file mode 100644 index 0000000000..bdfdf7fbe3 --- /dev/null +++ b/browser/components/sessionstore/test/browser_restore_cookies_noOriginAttributes.js @@ -0,0 +1,191 @@ +/* + * Bug 1267910 - The regression test case for session cookies. + */ + +"use strict"; + +const TEST_HOST = "www.example.com"; +const COOKIE = { + name: "test1", + value: "yes1", + path: "/browser/browser/components/sessionstore/test/", +}; +const SESSION_DATA = JSON.stringify({ + version: ["sessionrestore", 1], + windows: [ + { + tabs: [ + { + entries: [], + lastAccessed: 1463893009797, + hidden: false, + attributes: {}, + image: null, + }, + { + entries: [ + { + url: "http://www.example.com/browser/browser/components/sessionstore/test/browser_1267910_page.html", + triggeringPrincipal_base64, + charset: "UTF-8", + ID: 0, + docshellID: 2, + originalURI: + "http://www.example.com/browser/browser/components/sessionstore/test/browser_1267910_page.html", + docIdentifier: 0, + persist: true, + }, + ], + lastAccessed: 1463893009321, + hidden: false, + attributes: {}, + userContextId: 0, + index: 1, + image: "http://www.example.com/favicon.ico", + }, + ], + selected: 1, + _closedTabs: [], + busy: false, + width: 1024, + height: 768, + screenX: 4, + screenY: 23, + sizemode: "normal", + cookies: [ + { + host: "www.example.com", + value: "yes1", + path: "/browser/browser/components/sessionstore/test/", + name: "test1", + }, + ], + }, + ], + selectedWindow: 1, + _closedWindows: [], + session: { + lastUpdate: 1463893009801, + startTime: 1463893007134, + recentCrashes: 0, + }, + global: {}, +}); + +const SESSION_DATA_OA = JSON.stringify({ + version: ["sessionrestore", 1], + windows: [ + { + tabs: [ + { + entries: [], + lastAccessed: 1463893009797, + hidden: false, + attributes: {}, + image: null, + }, + { + entries: [ + { + url: "http://www.example.com/browser/browser/components/sessionstore/test/browser_1267910_page.html", + triggeringPrincipal_base64, + charset: "UTF-8", + ID: 0, + docshellID: 2, + originalURI: + "http://www.example.com/browser/browser/components/sessionstore/test/browser_1267910_page.html", + docIdentifier: 0, + persist: true, + }, + ], + lastAccessed: 1463893009321, + hidden: false, + attributes: {}, + userContextId: 0, + index: 1, + image: "http://www.example.com/favicon.ico", + }, + ], + selected: 1, + _closedTabs: [], + busy: false, + width: 1024, + height: 768, + screenX: 4, + screenY: 23, + sizemode: "normal", + cookies: [ + { + host: "www.example.com", + value: "yes1", + path: "/browser/browser/components/sessionstore/test/", + name: "test1", + originAttributes: { + addonId: "", + inIsolatedMozBrowser: false, + userContextId: 0, + }, + }, + ], + }, + ], + selectedWindow: 1, + _closedWindows: [], + session: { + lastUpdate: 1463893009801, + startTime: 1463893007134, + recentCrashes: 0, + }, + global: {}, +}); + +add_task(async function run_test() { + // Wait until initialization is complete. + await SessionStore.promiseInitialized; + + // Clear cookies. + Services.cookies.removeAll(); + + // Open a new window. + let win = await promiseNewWindowLoaded(); + + // Restore window with session cookies that have no originAttributes. + await setWindowState(win, SESSION_DATA, true); + + let cookieCount = 0; + for (var cookie of Services.cookies.getCookiesFromHost(TEST_HOST, {})) { + cookieCount++; + } + + // Check that the cookie is restored successfully. + is(cookieCount, 1, "expected one cookie"); + is(cookie.name, COOKIE.name, "cookie name successfully restored"); + is(cookie.value, COOKIE.value, "cookie value successfully restored"); + is(cookie.path, COOKIE.path, "cookie path successfully restored"); + + // Clear cookies. + Services.cookies.removeAll(); + + // In real usage, the event loop would get to spin between setWindowState + // uses. Without a spin, we can defer handling the STATE_STOP that + // removes the progress listener until after the mozbrowser has been + // destroyed, causing a window leak. + await new Promise(resolve => win.setTimeout(resolve, 0)); + + // Restore window with session cookies that have originAttributes within. + await setWindowState(win, SESSION_DATA_OA, true); + + cookieCount = 0; + for (cookie of Services.cookies.getCookiesFromHost(TEST_HOST, {})) { + cookieCount++; + } + + // Check that the cookie is restored successfully. + is(cookieCount, 1, "expected one cookie"); + is(cookie.name, COOKIE.name, "cookie name successfully restored"); + is(cookie.value, COOKIE.value, "cookie value successfully restored"); + is(cookie.path, COOKIE.path, "cookie path successfully restored"); + + // Close our window. + await BrowserTestUtils.closeWindow(win); +}); diff --git a/browser/components/sessionstore/test/browser_restore_pageProxyState.js b/browser/components/sessionstore/test/browser_restore_pageProxyState.js new file mode 100644 index 0000000000..f98237c7e8 --- /dev/null +++ b/browser/components/sessionstore/test/browser_restore_pageProxyState.js @@ -0,0 +1,77 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const BACKUP_STATE = SessionStore.getBrowserState(); +registerCleanupFunction(() => promiseBrowserState(BACKUP_STATE)); + +// The pageproxystate of the restored tab controls whether the identity +// information in the URL bar will display correctly. See bug 1766951 for more +// context. +async function test_pageProxyState(url1, url2) { + info(`urls: "${url1}", "${url2}"`); + + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.sessionstore.restore_on_demand", true], + ["browser.sessionstore.restore_tabs_lazily", true], + ], + }); + + await promiseBrowserState({ + windows: [ + { + tabs: [ + { + entries: [ + { + url: url1, + triggeringPrincipal_base64, + }, + ], + }, + { + entries: [ + { + url: url2, + triggeringPrincipal_base64, + }, + ], + }, + ], + selected: 1, + }, + ], + }); + + // The first tab isn't lazy and should be initialized. + ok(gBrowser.tabs[0].linkedPanel, "first tab is not lazy"); + is(gBrowser.selectedTab, gBrowser.tabs[0], "first tab is selected"); + is(gBrowser.userTypedValue, null, "no user typed value"); + is( + gURLBar.getAttribute("pageproxystate"), + "valid", + "has valid page proxy state" + ); + + // The second tab is lazy until selected. + ok(!gBrowser.tabs[1].linkedPanel, "second tab should be lazy"); + gBrowser.selectedTab = gBrowser.tabs[1]; + await promiseTabRestored(gBrowser.tabs[1]); + is(gBrowser.userTypedValue, null, "no user typed value"); + is( + gURLBar.getAttribute("pageproxystate"), + "valid", + "has valid page proxy state" + ); +} + +add_task(async function test_system() { + await test_pageProxyState("about:support", "about:addons"); +}); + +add_task(async function test_http() { + await test_pageProxyState( + "https://example.com/document-builder.sjs?html=tab1", + "https://example.com/document-builder.sjs?html=tab2" + ); +}); diff --git a/browser/components/sessionstore/test/browser_restore_private_tab_os.js b/browser/components/sessionstore/test/browser_restore_private_tab_os.js new file mode 100644 index 0000000000..3041fe6f39 --- /dev/null +++ b/browser/components/sessionstore/test/browser_restore_private_tab_os.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/. */ + +"use strict"; + +const TEST_URI = + "https://example.com/" + + "browser/browser/components/sessionstore/test/empty.html"; + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + // Set the pref to true so we know exactly how many tabs should be restoring at + // any given time. This guarantees that a finishing load won't start another. + ["browser.sessionstore.restore_on_demand", true], + // Don't restore tabs lazily. + ["browser.sessionstore.restore_tabs_lazily", true], + // don't preload tabs so we don't have extra XULFrameLoaderCreated events + // firing + ["browser.newtab.preload", false], + ], + }); +}); + +add_task(async function testRestore() { + // Clear the list of closed windows. + forgetClosedWindows(); + + // Create a new private window + let win = await promiseNewWindowLoaded({ private: true }); + + // Create a new private tab + let tab = BrowserTestUtils.addTab(win.gBrowser, TEST_URI); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + await TabStateFlusher.flush(browser); + + // Ensure that closed tabs in a private windows can be restored. + win.gBrowser.removeTab(tab); + is(ss.getClosedTabCountForWindow(win), 1, "there is a single tab to restore"); + + tab = SessionStore.undoCloseTab(win, 0); + info(`Undo close tab`); + browser = tab.linkedBrowser; + await promiseTabRestored(tab); + info(`Private tab restored`); + + let expectedRemoteType = gFissionBrowser + ? "webIsolated=https://example.com^privateBrowsingId=1" + : "web"; + is(browser.remoteType, expectedRemoteType, "correct remote type"); + + await BrowserTestUtils.closeWindow(win); + + // Cleanup + info("Forgetting closed tabs"); + forgetClosedTabs(window); +}); diff --git a/browser/components/sessionstore/test/browser_restore_redirect.js b/browser/components/sessionstore/test/browser_restore_redirect.js new file mode 100644 index 0000000000..206b783191 --- /dev/null +++ b/browser/components/sessionstore/test/browser_restore_redirect.js @@ -0,0 +1,72 @@ +"use strict"; + +const BASE = "http://example.com/browser/browser/components/sessionstore/test/"; +const TARGET = BASE + "restore_redirect_target.html"; + +/** + * Ensure that a http redirect leaves a working tab. + */ +add_task(async function check_http_redirect() { + let state = { + entries: [ + { url: BASE + "restore_redirect_http.html", triggeringPrincipal_base64 }, + ], + }; + + // Open a new tab to restore into. + let tab = BrowserTestUtils.addTab(gBrowser, "about:blank"); + let browser = tab.linkedBrowser; + await promiseTabState(tab, state); + + info("Restored tab"); + + await TabStateFlusher.flush(browser); + let data = TabState.collect(tab); + is(data.entries.length, 1, "Should be one entry in session history"); + is(data.entries[0].url, TARGET, "Should be the right session history entry"); + + ok( + !ss.getInternalObjectState(browser), + "Temporary restore data should have been cleared" + ); + + // Cleanup. + BrowserTestUtils.removeTab(tab); +}); + +/** + * Ensure that a js redirect leaves a working tab. + */ +add_task(async function check_js_redirect() { + let state = { + entries: [ + { url: BASE + "restore_redirect_js.html", triggeringPrincipal_base64 }, + ], + }; + + // Open a new tab to restore into. + let tab = BrowserTestUtils.addTab(gBrowser, "about:blank"); + let browser = tab.linkedBrowser; + let loadPromise = BrowserTestUtils.browserLoaded(browser, true, url => + url.endsWith("restore_redirect_target.html") + ); + + await promiseTabState(tab, state); + + info("Restored tab"); + + await loadPromise; + + await TabStateFlusher.flush(browser); + let data = TabState.collect(tab); + is(data.entries.length, 1, "Should be one entry in session history"); + is(data.entries[0].url, TARGET, "Should be the right session history entry"); + + ok( + !ss.getInternalObjectState(browser), + "Temporary restore data should have been cleared" + ); + + // Cleanup. + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/sessionstore/test/browser_restore_reversed_z_order.js b/browser/components/sessionstore/test/browser_restore_reversed_z_order.js new file mode 100644 index 0000000000..33d96fd8da --- /dev/null +++ b/browser/components/sessionstore/test/browser_restore_reversed_z_order.js @@ -0,0 +1,125 @@ +"use strict"; + +const PRIMARY_WINDOW = window; + +let gTestURLsMap = new Map([ + ["about:about", null], + ["about:license", null], + ["about:robots", null], + ["about:mozilla", null], +]); +let gBrowserState; + +add_setup(async function () { + let windows = []; + let count = 0; + for (let url of gTestURLsMap.keys()) { + let window = !count + ? PRIMARY_WINDOW + : await BrowserTestUtils.openNewBrowserWindow(); + let browserLoaded = BrowserTestUtils.browserLoaded( + window.gBrowser.selectedBrowser + ); + BrowserTestUtils.loadURIString(window.gBrowser.selectedBrowser, url); + await browserLoaded; + // Capture the title. + gTestURLsMap.set(url, window.gBrowser.selectedTab.label); + // Minimize the before-last window, to have a different window feature added + // to the test. + if (count == gTestURLsMap.size - 1) { + let activated = BrowserTestUtils.waitForEvent( + windows[count - 1], + "activate" + ); + window.minimize(); + await activated; + } + windows.push(window); + ++count; + } + + // Wait until we get the lastest history from all windows. + await Promise.all(windows.map(window => TabStateFlusher.flushWindow(window))); + + gBrowserState = ss.getBrowserState(); + + await promiseAllButPrimaryWindowClosed(); +}); + +add_task(async function test_z_indices_are_saved_correctly() { + let state = JSON.parse(gBrowserState); + Assert.equal( + state.windows.length, + gTestURLsMap.size, + "Correct number of windows saved" + ); + + // Check if we saved state in correct order of creation. + let idx = 0; + for (let url of gTestURLsMap.keys()) { + Assert.equal( + state.windows[idx].tabs[0].entries[0].url, + url, + `Window #${idx} is stored in correct creation order` + ); + ++idx; + } + + // Check if we saved a valid zIndex (no null, no undefined or no 0). + for (let window of state.windows) { + Assert.ok(window.zIndex, "A valid zIndex is stored"); + } + + Assert.equal( + state.windows[0].zIndex, + 3, + "Window #1 should have the correct z-index" + ); + Assert.equal( + state.windows[1].zIndex, + 2, + "Window #2 should have correct z-index" + ); + Assert.equal( + state.windows[2].zIndex, + 1, + "Window #3 should be the topmost window" + ); + Assert.equal( + state.windows[3].zIndex, + 4, + "Minimized window should be the last window to restore" + ); +}); + +add_task(async function test_windows_are_restored_in_reversed_z_order() { + await promiseBrowserState(gBrowserState); + + let indexedTabLabels = [...gTestURLsMap.values()]; + let tabsRestoredLabels = BrowserWindowTracker.orderedWindows.map( + window => window.gBrowser.selectedTab.label + ); + + Assert.equal( + tabsRestoredLabels[0], + indexedTabLabels[2], + "First restored tab should be last used tab" + ); + Assert.equal( + tabsRestoredLabels[1], + indexedTabLabels[1], + "Second restored tab is correct" + ); + Assert.equal( + tabsRestoredLabels[2], + indexedTabLabels[0], + "Third restored tab is correct" + ); + Assert.equal( + tabsRestoredLabels[3], + indexedTabLabels[3], + "Last restored tab should be a minimized window" + ); + + await promiseAllButPrimaryWindowClosed(); +}); diff --git a/browser/components/sessionstore/test/browser_restore_session_in_undoCloseTab.js b/browser/components/sessionstore/test/browser_restore_session_in_undoCloseTab.js new file mode 100644 index 0000000000..a64f432eeb --- /dev/null +++ b/browser/components/sessionstore/test/browser_restore_session_in_undoCloseTab.js @@ -0,0 +1,56 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { _LastSession } = ChromeUtils.importESModule( + "resource:///modules/sessionstore/SessionStore.sys.mjs" +); + +/** + * Tests that if the user invokes undoCloseTab in a window for which there are no + * tabs to undo closing, that we attempt to restore the previous session if one + * exists. + */ +add_task(async function test_restore_session_in_undoCloseTab() { + forgetClosedTabs(window); + registerCleanupFunction(() => { + forgetClosedTabs(window); + }); + + const state = { + windows: [ + { + tabs: [ + { + entries: [ + { + url: "https://example.org/", + triggeringPrincipal_base64, + }, + ], + }, + { + entries: [ + { + url: "https://example.com/", + triggeringPrincipal_base64, + }, + ], + }, + ], + selected: 2, + }, + ], + }; + + _LastSession.setState(state); + + let sessionRestored = promiseSessionStoreLoads(2 /* total restored tabs */); + let result = undoCloseTab(); + await sessionRestored; + Assert.equal(result, null); + Assert.equal(gBrowser.tabs.length, 2); + + gBrowser.removeAllTabsBut(gBrowser.tabs[0]); +}); diff --git a/browser/components/sessionstore/test/browser_restore_srcdoc.js b/browser/components/sessionstore/test/browser_restore_srcdoc.js new file mode 100644 index 0000000000..9e670100ea --- /dev/null +++ b/browser/components/sessionstore/test/browser_restore_srcdoc.js @@ -0,0 +1,44 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +function makeURL(srcdocValue) { + return `data:text/html;charset=utf-8," + + "clickme" + + "clickme"; + + // Create a new tab. + let tab = BrowserTestUtils.addTab(gBrowser, URL); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + + // Check that we have a single shistory entry. + await TabStateFlusher.flush(browser); + let { entries } = JSON.parse(ss.getTabState(tab)); + is(entries.length, 1, "there is one shistory entry"); + is(entries[0].children.length, 1, "the entry has one child"); + + // Navigate the subframe. + await SpecialPowers.spawn(browser, [], async function () { + content.document.querySelector("#a1").click(); + }); + await promiseBrowserLoaded( + browser, + false /* wait for subframe load only */, + "http://example.com/1" + ); + + // Check shistory. + await TabStateFlusher.flush(browser); + ({ entries } = JSON.parse(ss.getTabState(tab))); + is(entries.length, 2, "there now are two shistory entries"); + is(entries[1].children.length, 1, "the second entry has one child"); + + // Go back in history. + let goneBack = promiseBrowserLoaded( + browser, + false /* wait for subframe load only */, + "http://example.com/" + ); + info("About to go back in history"); + browser.goBack(); + await goneBack; + + // Navigate the subframe again. + let eventPromise = BrowserTestUtils.waitForContentEvent( + browser, + "hashchange", + true + ); + await SpecialPowers.spawn(browser, [], async function () { + content.document.querySelector("#a2").click(); + }); + await eventPromise; + + // Check shistory. + await TabStateFlusher.flush(browser); + ({ entries } = JSON.parse(ss.getTabState(tab))); + is(entries.length, 2, "there now are two shistory entries"); + is(entries[1].children.length, 1, "the second entry has one child"); + + // Cleanup. + gBrowser.removeTab(tab); +}); + +/** + * Ensure that navigating from an about page invalidates shistory. + */ +add_task(async function test_about_page_navigate() { + // Create a new tab. + let tab = BrowserTestUtils.addTab(gBrowser, "about:blank"); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + + // Check that we have a single shistory entry. + await TabStateFlusher.flush(browser); + let { entries } = JSON.parse(ss.getTabState(tab)); + is(entries.length, 1, "there is one shistory entry"); + is(entries[0].url, "about:blank", "url is correct"); + + // Verify that the title is also recorded. + is(entries[0].title, "about:blank", "title is correct"); + + BrowserTestUtils.loadURIString(browser, "about:robots"); + await promiseBrowserLoaded(browser); + + // Check that we have changed the history entry. + await TabStateFlusher.flush(browser); + ({ entries } = JSON.parse(ss.getTabState(tab))); + is(entries.length, 1, "there is one shistory entry"); + is(entries[0].url, "about:robots", "url is correct"); + + // Cleanup. + gBrowser.removeTab(tab); +}); + +/** + * Ensure that history.pushState and history.replaceState invalidate shistory. + */ +add_task(async function test_pushstate_replacestate() { + // Create a new tab. + let tab = BrowserTestUtils.addTab(gBrowser, "http://example.com/1"); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + + // Check that we have a single shistory entry. + await TabStateFlusher.flush(browser); + let { entries } = JSON.parse(ss.getTabState(tab)); + is(entries.length, 1, "there is one shistory entry"); + is(entries[0].url, "http://example.com/1", "url is correct"); + + await SpecialPowers.spawn(browser, [], async function () { + content.window.history.pushState({}, "", "test-entry/"); + }); + + // Check that we have added the history entry. + await TabStateFlusher.flush(browser); + ({ entries } = JSON.parse(ss.getTabState(tab))); + is(entries.length, 2, "there is another shistory entry"); + is(entries[1].url, "http://example.com/test-entry/", "url is correct"); + + await SpecialPowers.spawn(browser, [], async function () { + content.window.history.replaceState({}, "", "test-entry2/"); + }); + + // Check that we have modified the history entry. + await TabStateFlusher.flush(browser); + ({ entries } = JSON.parse(ss.getTabState(tab))); + is(entries.length, 2, "there is still two shistory entries"); + is( + entries[1].url, + "http://example.com/test-entry/test-entry2/", + "url is correct" + ); + + // Cleanup. + gBrowser.removeTab(tab); +}); + +/** + * Ensure that slow loading subframes will invalidate shistory. + */ +add_task(async function test_slow_subframe_load() { + const SLOW_URL = + "http://mochi.test:8888/browser/browser/components/" + + "sessionstore/test/browser_sessionHistory_slow.sjs"; + + const URL = + "data:text/html;charset=utf-8," + + "" + + "" + + ""; + + // Add a new tab with a slow loading subframe + let tab = BrowserTestUtils.addTab(gBrowser, URL); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + + await TabStateFlusher.flush(browser); + let { entries } = JSON.parse(ss.getTabState(tab)); + + // Check the number of children. + is(entries.length, 1, "there is one root entry ..."); + is(entries[0].children.length, 1, "... with one child entries"); + + // Check URLs. + ok(entries[0].url.startsWith("data:text/html"), "correct root url"); + is(entries[0].children[0].url, SLOW_URL, "correct url for subframe"); + + // Cleanup. + gBrowser.removeTab(tab); +}); + +/** + * Ensure that document wireframes can be persisted when they're enabled. + */ +add_task(async function test_wireframes() { + // Wireframes only works when Fission and SHIP are enabled. + if ( + !Services.appinfo.fissionAutostart || + !Services.appinfo.sessionHistoryInParent + ) { + ok(true, "Skipping test_wireframes when Fission or SHIP is not enabled."); + return; + } + + await SpecialPowers.pushPrefEnv({ + set: [["browser.history.collectWireframes", true]], + }); + + let tab = BrowserTestUtils.addTab(gBrowser, "http://example.com"); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + + await TabStateFlusher.flush(browser); + let { entries } = JSON.parse(ss.getTabState(tab)); + + // Check the number of children. + is(entries.length, 1, "there is one shistory entry"); + + // Check for the wireframe + ok(entries[0].wireframe, "A wireframe was captured and serialized."); + ok( + entries[0].wireframe.rects.length, + "Several wireframe rects were captured." + ); + + // Cleanup. + gBrowser.removeTab(tab); +}); diff --git a/browser/components/sessionstore/test/browser_sessionHistory_slow.sjs b/browser/components/sessionstore/test/browser_sessionHistory_slow.sjs new file mode 100644 index 0000000000..abb1dee829 --- /dev/null +++ b/browser/components/sessionstore/test/browser_sessionHistory_slow.sjs @@ -0,0 +1,22 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const DELAY_MS = "2000"; + +let timer; + +function handleRequest(req, resp) { + resp.processAsync(); + resp.setHeader("Cache-Control", "no-cache", false); + resp.setHeader("Content-Type", "text/html;charset=utf-8", false); + + timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + timer.init( + () => { + resp.write("hi"); + resp.finish(); + }, + DELAY_MS, + Ci.nsITimer.TYPE_ONE_SHOT + ); +} diff --git a/browser/components/sessionstore/test/browser_sessionStorage.html b/browser/components/sessionstore/test/browser_sessionStorage.html new file mode 100644 index 0000000000..f3664ebc9b --- /dev/null +++ b/browser/components/sessionstore/test/browser_sessionStorage.html @@ -0,0 +1,28 @@ + + + + + browser_sessionStorage.html + + + + + + + diff --git a/browser/components/sessionstore/test/browser_sessionStorage.js b/browser/components/sessionstore/test/browser_sessionStorage.js new file mode 100644 index 0000000000..00d46722e2 --- /dev/null +++ b/browser/components/sessionstore/test/browser_sessionStorage.js @@ -0,0 +1,298 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const RAND = Math.random(); +const URL = + "http://mochi.test:8888/browser/" + + "browser/components/sessionstore/test/browser_sessionStorage.html" + + "?" + + RAND; + +const HAS_FIRST_PARTY_DOMAIN = [ + Ci.nsICookieService.BEHAVIOR_REJECT_TRACKER_AND_PARTITION_FOREIGN, +].includes(Services.prefs.getIntPref("network.cookie.cookieBehavior")); +const OUTER_ORIGIN = "http://mochi.test:8888"; +const FIRST_PARTY_DOMAIN = escape("(http,mochi.test)"); +const INNER_ORIGIN = HAS_FIRST_PARTY_DOMAIN + ? `http://example.com^partitionKey=${FIRST_PARTY_DOMAIN}` + : "http://example.com"; +const SECURE_INNER_ORIGIN = HAS_FIRST_PARTY_DOMAIN + ? `https://example.com^partitionKey=${FIRST_PARTY_DOMAIN}` + : "https://example.com"; + +const OUTER_VALUE = "outer-value-" + RAND; +const INNER_VALUE = "inner-value-" + RAND; + +/** + * This test ensures that setting, modifying and restoring sessionStorage data + * works as expected. + */ +add_task(async function session_storage() { + let tab = BrowserTestUtils.addTab(gBrowser, URL); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + + // Flush to make sure chrome received all data. + await TabStateFlusher.flush(browser); + + let { storage } = JSON.parse(ss.getTabState(tab)); + is( + storage[INNER_ORIGIN].test, + INNER_VALUE, + "sessionStorage data for example.com has been serialized correctly" + ); + is( + storage[OUTER_ORIGIN].test, + OUTER_VALUE, + "sessionStorage data for mochi.test has been serialized correctly" + ); + + // Ensure that modifying sessionStore values works for the inner frame only. + await modifySessionStorage(browser, { test: "modified1" }, { frameIndex: 0 }); + await TabStateFlusher.flush(browser); + + ({ storage } = JSON.parse(ss.getTabState(tab))); + is( + storage[INNER_ORIGIN].test, + "modified1", + "sessionStorage data for example.com has been serialized correctly" + ); + is( + storage[OUTER_ORIGIN].test, + OUTER_VALUE, + "sessionStorage data for mochi.test has been serialized correctly" + ); + + // Ensure that modifying sessionStore values works for both frames. + await modifySessionStorage(browser, { test: "modified" }); + await modifySessionStorage(browser, { test: "modified2" }, { frameIndex: 0 }); + await TabStateFlusher.flush(browser); + + ({ storage } = JSON.parse(ss.getTabState(tab))); + is( + storage[INNER_ORIGIN].test, + "modified2", + "sessionStorage data for example.com has been serialized correctly" + ); + is( + storage[OUTER_ORIGIN].test, + "modified", + "sessionStorage data for mochi.test has been serialized correctly" + ); + + // Test that duplicating a tab works. + let tab2 = gBrowser.duplicateTab(tab); + let browser2 = tab2.linkedBrowser; + await promiseTabRestored(tab2); + + // Flush to make sure chrome received all data. + await TabStateFlusher.flush(browser2); + + ({ storage } = JSON.parse(ss.getTabState(tab2))); + is( + storage[INNER_ORIGIN].test, + "modified2", + "sessionStorage data for example.com has been duplicated correctly" + ); + is( + storage[OUTER_ORIGIN].test, + "modified", + "sessionStorage data for mochi.test has been duplicated correctly" + ); + + // Ensure that the content script retains restored data + // (by e.g. duplicateTab) and sends it along with new data. + await modifySessionStorage(browser2, { test: "modified3" }); + await TabStateFlusher.flush(browser2); + + ({ storage } = JSON.parse(ss.getTabState(tab2))); + is( + storage[INNER_ORIGIN].test, + "modified2", + "sessionStorage data for example.com has been duplicated correctly" + ); + is( + storage[OUTER_ORIGIN].test, + "modified3", + "sessionStorage data for mochi.test has been duplicated correctly" + ); + + // Check that loading a new URL discards data. + BrowserTestUtils.loadURIString(browser2, "http://mochi.test:8888/"); + await promiseBrowserLoaded(browser2); + await TabStateFlusher.flush(browser2); + + ({ storage } = JSON.parse(ss.getTabState(tab2))); + is( + storage[OUTER_ORIGIN].test, + "modified3", + "navigating retains correct storage data" + ); + + is( + storage[INNER_ORIGIN].test, + "modified2", + "sessionStorage data for example.com wasn't discarded after top-level same-site navigation" + ); + + // Test that clearing the data in the first tab works properly within + // the subframe + await modifySessionStorage(browser, {}, { frameIndex: 0 }); + await TabStateFlusher.flush(browser); + ({ storage } = JSON.parse(ss.getTabState(tab))); + is( + storage[INNER_ORIGIN], + undefined, + "sessionStorage data for example.com has been cleared correctly" + ); + + // Test that clearing the data in the first tab works properly within + // the top-level frame + await modifySessionStorage(browser, {}); + await TabStateFlusher.flush(browser); + ({ storage } = JSON.parse(ss.getTabState(tab))); + ok( + storage === null || storage === undefined, + "sessionStorage data for the entire tab has been cleared correctly" + ); + + // Clean up. + BrowserTestUtils.removeTab(tab); + BrowserTestUtils.removeTab(tab2); +}); + +/** + * This test ensures that purging domain data also purges data from the + * sessionStorage data collected for tabs. + */ +add_task(async function purge_domain() { + let tab = BrowserTestUtils.addTab(gBrowser, URL); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + + // Purge data for "mochi.test". + await purgeDomainData(browser, "mochi.test"); + + // Flush to make sure chrome received all data. + await TabStateFlusher.flush(browser); + + let { storage } = JSON.parse(ss.getTabState(tab)); + ok( + !storage[OUTER_ORIGIN], + "sessionStorage data for mochi.test has been purged" + ); + is( + storage[INNER_ORIGIN].test, + INNER_VALUE, + "sessionStorage data for example.com has been preserved" + ); + + BrowserTestUtils.removeTab(tab); +}); + +/** + * This test ensures that collecting sessionStorage data respects the privacy + * levels as set by the user. + */ +add_task(async function respect_privacy_level() { + let tab = BrowserTestUtils.addTab(gBrowser, URL + "&secure"); + await promiseBrowserLoaded(tab.linkedBrowser); + await TabStateFlusher.flush(tab.linkedBrowser); + await promiseRemoveTabAndSessionState(tab); + + let [ + { + state: { storage }, + }, + ] = ss.getClosedTabDataForWindow(window); + is( + storage[OUTER_ORIGIN].test, + OUTER_VALUE, + "http sessionStorage data has been saved" + ); + is( + storage[SECURE_INNER_ORIGIN].test, + INNER_VALUE, + "https sessionStorage data has been saved" + ); + + // Disable saving data for encrypted sites. + Services.prefs.setIntPref("browser.sessionstore.privacy_level", 1); + + tab = BrowserTestUtils.addTab(gBrowser, URL + "&secure"); + await promiseBrowserLoaded(tab.linkedBrowser); + await TabStateFlusher.flush(tab.linkedBrowser); + await promiseRemoveTabAndSessionState(tab); + + [ + { + state: { storage }, + }, + ] = ss.getClosedTabDataForWindow(window); + is( + storage[OUTER_ORIGIN].test, + OUTER_VALUE, + "http sessionStorage data has been saved" + ); + ok( + !storage[SECURE_INNER_ORIGIN], + "https sessionStorage data has *not* been saved" + ); + + // Disable saving data for any site. + Services.prefs.setIntPref("browser.sessionstore.privacy_level", 2); + + // Check that duplicating a tab copies all private data. + tab = BrowserTestUtils.addTab(gBrowser, URL + "&secure"); + await promiseBrowserLoaded(tab.linkedBrowser); + let tab2 = gBrowser.duplicateTab(tab); + await promiseTabRestored(tab2); + await promiseRemoveTabAndSessionState(tab); + + // With privacy_level=2 the |tab| shouldn't have any sessionStorage data. + [ + { + state: { storage }, + }, + ] = ss.getClosedTabDataForWindow(window); + ok(!storage, "sessionStorage data has *not* been saved"); + + // Remove all closed tabs before continuing with the next test. + // As Date.now() isn't monotonic we might sometimes check + // the wrong closedTabData entry. + forgetClosedTabs(window); + + // Restore the default privacy level and close the duplicated tab. + Services.prefs.clearUserPref("browser.sessionstore.privacy_level"); + await promiseRemoveTabAndSessionState(tab2); + + // With privacy_level=0 the duplicated |tab2| should persist all data. + [ + { + state: { storage }, + }, + ] = ss.getClosedTabDataForWindow(window); + is( + storage[OUTER_ORIGIN].test, + OUTER_VALUE, + "http sessionStorage data has been saved" + ); + is( + storage[SECURE_INNER_ORIGIN].test, + INNER_VALUE, + "https sessionStorage data has been saved" + ); +}); + +function purgeDomainData(browser, domain) { + return new Promise(resolve => { + Services.clearData.deleteDataFromHost( + domain, + true, + Services.clearData.CLEAR_SESSION_HISTORY, + resolve + ); + }); +} diff --git a/browser/components/sessionstore/test/browser_sessionStorage_size.js b/browser/components/sessionstore/test/browser_sessionStorage_size.js new file mode 100644 index 0000000000..1045482817 --- /dev/null +++ b/browser/components/sessionstore/test/browser_sessionStorage_size.js @@ -0,0 +1,34 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const RAND = Math.random(); +const URL = + "http://mochi.test:8888/browser/" + + "browser/components/sessionstore/test/browser_sessionStorage.html" + + "?" + + RAND; + +const OUTER_VALUE = "outer-value-" + RAND; + +// Lower the size limit for DOM Storage content. Check that DOM Storage +// is not updated, but that other things remain updated. +add_task(async function test_large_content() { + Services.prefs.setIntPref("browser.sessionstore.dom_storage_limit", 5); + + let tab = BrowserTestUtils.addTab(gBrowser, URL); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + + // Flush to make sure chrome received all data. + await TabStateFlusher.flush(browser); + + let state = JSON.parse(ss.getTabState(tab)); + info(JSON.stringify(state, null, "\t")); + Assert.equal(state.storage, null, "We have no storage for the tab"); + Assert.equal(state.entries[0].title, OUTER_VALUE); + BrowserTestUtils.removeTab(tab); + + Services.prefs.clearUserPref("browser.sessionstore.dom_storage_limit"); +}); diff --git a/browser/components/sessionstore/test/browser_sessionStoreContainer.js b/browser/components/sessionstore/test/browser_sessionStoreContainer.js new file mode 100644 index 0000000000..1c2a82305f --- /dev/null +++ b/browser/components/sessionstore/test/browser_sessionStoreContainer.js @@ -0,0 +1,165 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +add_task(async function () { + for (let i = 0; i < 3; ++i) { + let tab = BrowserTestUtils.addTab(gBrowser, "http://example.com/", { + userContextId: i, + }); + let browser = tab.linkedBrowser; + + await promiseBrowserLoaded(browser); + + let tab2 = gBrowser.duplicateTab(tab); + Assert.equal(tab2.getAttribute("usercontextid"), i); + let browser2 = tab2.linkedBrowser; + await promiseTabRestored(tab2); + + await SpecialPowers.spawn( + browser2, + [{ expectedId: i }], + async function (args) { + let loadContext = docShell.QueryInterface(Ci.nsILoadContext); + Assert.equal( + loadContext.originAttributes.userContextId, + args.expectedId, + "The docShell has the correct userContextId" + ); + } + ); + + BrowserTestUtils.removeTab(tab); + BrowserTestUtils.removeTab(tab2); + } +}); + +add_task(async function () { + let tab = BrowserTestUtils.addTab(gBrowser, "http://example.com/", { + userContextId: 1, + }); + let browser = tab.linkedBrowser; + + await promiseBrowserLoaded(browser); + + gBrowser.selectedTab = tab; + + let tab2 = gBrowser.duplicateTab(tab); + let browser2 = tab2.linkedBrowser; + await promiseTabRestored(tab2); + + await SpecialPowers.spawn( + browser2, + [{ expectedId: 1 }], + async function (args) { + Assert.equal( + docShell.getOriginAttributes().userContextId, + args.expectedId, + "The docShell has the correct userContextId" + ); + } + ); + + BrowserTestUtils.removeTab(tab); + BrowserTestUtils.removeTab(tab2); +}); + +add_task(async function () { + let tab = BrowserTestUtils.addTab(gBrowser, "http://example.com/", { + userContextId: 1, + }); + let browser = tab.linkedBrowser; + + await promiseBrowserLoaded(browser); + + gBrowser.removeTab(tab); + + let tab2 = ss.undoCloseTab(window, 0); + Assert.equal(tab2.getAttribute("usercontextid"), 1); + await promiseTabRestored(tab2); + await SpecialPowers.spawn( + tab2.linkedBrowser, + [{ expectedId: 1 }], + async function (args) { + Assert.equal( + docShell.getOriginAttributes().userContextId, + args.expectedId, + "The docShell has the correct userContextId" + ); + } + ); + + BrowserTestUtils.removeTab(tab2); +}); + +// Opens "uri" in a new tab with the provided userContextId and focuses it. +// Returns the newly opened tab. +async function openTabInUserContext(userContextId) { + // Open the tab in the correct userContextId. + let tab = BrowserTestUtils.addTab(gBrowser, "http://example.com", { + userContextId, + }); + + // Select tab and make sure its browser is focused. + gBrowser.selectedTab = tab; + tab.ownerGlobal.focus(); + + let browser = gBrowser.getBrowserForTab(tab); + await BrowserTestUtils.browserLoaded(browser); + return { tab, browser }; +} + +function waitForNewCookie() { + return new Promise(resolve => { + Services.obs.addObserver(function observer(subj, topic, data) { + if (data == "added") { + Services.obs.removeObserver(observer, topic); + resolve(); + } + }, "session-cookie-changed"); + }); +} + +add_task(async function test() { + const USER_CONTEXTS = ["default", "personal", "work"]; + + // Make sure userContext is enabled. + await SpecialPowers.pushPrefEnv({ + set: [["privacy.userContext.enabled", true]], + }); + + Services.cookies.removeAll(); + + for (let userContextId of Object.keys(USER_CONTEXTS)) { + // Load the page in 3 different contexts and set a cookie + // which should only be visible in that context. + let cookie = USER_CONTEXTS[userContextId]; + + // Open our tab in the given user context. + let { tab, browser } = await openTabInUserContext(userContextId); + + await Promise.all([ + waitForNewCookie(), + SpecialPowers.spawn( + browser, + [cookie], + passedCookie => (content.document.cookie = passedCookie) + ), + ]); + + // Ensure the tab's session history is up-to-date. + await TabStateFlusher.flush(browser); + + // Remove the tab. + gBrowser.removeTab(tab); + } + + let state = JSON.parse(SessionStore.getBrowserState()); + is( + state.cookies.length, + USER_CONTEXTS.length, + "session restore should have each container's cookie" + ); +}); diff --git a/browser/components/sessionstore/test/browser_sizemodeBeforeMinimized.js b/browser/components/sessionstore/test/browser_sizemodeBeforeMinimized.js new file mode 100644 index 0000000000..a832e71bcf --- /dev/null +++ b/browser/components/sessionstore/test/browser_sizemodeBeforeMinimized.js @@ -0,0 +1,44 @@ +add_task(async function test() { + // Test for bugfix 384278. Confirms that sizemodeBeforeMinimized is set properly when window state is saved. + let win = await BrowserTestUtils.openNewBrowserWindow(); + + async function changeSizeMode(mode) { + let promise = BrowserTestUtils.waitForEvent(win, "sizemodechange"); + win[mode](); + await promise; + } + + function checkCurrentState(sizemodeBeforeMinimized) { + let state = ss.getWindowState(win); + let winState = state.windows[0]; + is( + winState.sizemodeBeforeMinimized, + sizemodeBeforeMinimized, + "sizemodeBeforeMinimized should match" + ); + } + + // Note: Uses ss.getWindowState(win); as a more time efficient alternative to forceSaveState(); (causing timeouts). + // Simulates FF restart. + + if (win.windowState != win.STATE_NORMAL) { + await changeSizeMode("restore"); + } + ss.getWindowState(win); + await changeSizeMode("minimize"); + checkCurrentState("normal"); + + // Need to create new window or test will timeout on linux. + await BrowserTestUtils.closeWindow(win); + win = await BrowserTestUtils.openNewBrowserWindow(); + + if (win.windowState != win.STATE_MAXIMIZED) { + await changeSizeMode("maximize"); + } + ss.getWindowState(win); + await changeSizeMode("minimize"); + checkCurrentState("maximized"); + + // Clean up. + await BrowserTestUtils.closeWindow(win); +}); diff --git a/browser/components/sessionstore/test/browser_speculative_connect.html b/browser/components/sessionstore/test/browser_speculative_connect.html new file mode 100644 index 0000000000..a0fb88e0a6 --- /dev/null +++ b/browser/components/sessionstore/test/browser_speculative_connect.html @@ -0,0 +1,8 @@ + +
+ Dummy html page to test speculative connect +
+ + Hello Speculative Connect + + diff --git a/browser/components/sessionstore/test/browser_speculative_connect.js b/browser/components/sessionstore/test/browser_speculative_connect.js new file mode 100644 index 0000000000..bece5e7baa --- /dev/null +++ b/browser/components/sessionstore/test/browser_speculative_connect.js @@ -0,0 +1,145 @@ +const TEST_URLS = [ + "about:buildconfig", + "http://mochi.test:8888/browser/browser/components/sessionstore/test/browser_speculative_connect.html", + "", +]; + +/** + * This will open tabs in browser. This will also make the last tab + * inserted to be the selected tab. + */ +async function openTabs(win) { + for (let i = 0; i < TEST_URLS.length; ++i) { + await BrowserTestUtils.openNewForegroundTab(win.gBrowser, TEST_URLS[i]); + } +} + +add_task(async function speculative_connect_restore_on_demand() { + Services.prefs.setBoolPref("browser.sessionstore.restore_on_demand", true); + is( + Services.prefs.getBoolPref("browser.sessionstore.restore_on_demand"), + true, + "We're restoring on demand" + ); + forgetClosedWindows(); + + // Open a new window and populate with tabs. + let win = await promiseNewWindowLoaded(); + await openTabs(win); + + // Close the window. + await BrowserTestUtils.closeWindow(win); + + // Reopen a window. + let newWin = undoCloseWindow(0); + // Make sure we wait until this window is restored. + await promiseWindowRestored(newWin); + + let tabs = newWin.gBrowser.tabs; + is(tabs.length, TEST_URLS.length + 1, "Restored right number of tabs"); + + let e = new MouseEvent("mouseover"); + + // First tab should be ignored, since it's the default blank tab when we open a new window. + + // Trigger a mouse enter on second tab. + tabs[1].dispatchEvent(e); + ok( + !tabs[1].__test_connection_prepared, + "Second tab doesn't have a connection prepared" + ); + is(tabs[1].__test_connection_url, TEST_URLS[0], "Second tab has correct url"); + ok( + SessionStore.getLazyTabValue(tabs[1], "connectionPrepared"), + "Second tab should have connectionPrepared flag after hovered" + ); + + // Trigger a mouse enter on third tab. + tabs[2].dispatchEvent(e); + ok(tabs[2].__test_connection_prepared, "Third tab has a connection prepared"); + is(tabs[2].__test_connection_url, TEST_URLS[1], "Third tab has correct url"); + ok( + SessionStore.getLazyTabValue(tabs[2], "connectionPrepared"), + "Third tab should have connectionPrepared flag after hovered" + ); + + // Last tab is the previously selected tab. + tabs[3].dispatchEvent(e); + is( + SessionStore.getLazyTabValue(tabs[3], "connectionPrepared"), + undefined, + "Previous selected tab shouldn't have connectionPrepared flag" + ); + is( + tabs[3].__test_connection_prepared, + undefined, + "Previous selected tab should not have a connection prepared" + ); + is( + tabs[3].__test_connection_url, + undefined, + "Previous selected tab should not have a connection prepared" + ); + + await BrowserTestUtils.closeWindow(newWin); +}); + +add_task(async function speculative_connect_restore_automatically() { + Services.prefs.setBoolPref("browser.sessionstore.restore_on_demand", false); + is( + Services.prefs.getBoolPref("browser.sessionstore.restore_on_demand"), + false, + "We're restoring automatically" + ); + forgetClosedWindows(); + + // Open a new window and populate with tabs. + let win = await promiseNewWindowLoaded(); + await openTabs(win); + + // Close the window. + await BrowserTestUtils.closeWindow(win); + + // Reopen a window. + let newWin = undoCloseWindow(0); + // Make sure we wait until this window is restored. + await promiseWindowRestored(newWin); + + let tabs = newWin.gBrowser.tabs; + is(tabs.length, TEST_URLS.length + 1, "Restored right number of tabs"); + + // First tab is ignored, since it's the default tab open when we open new window + + // Second tab. + ok( + !tabs[1].__test_connection_prepared, + "Second tab doesn't have a connection prepared" + ); + is( + tabs[1].__test_connection_url, + TEST_URLS[0], + "Second tab has correct host url" + ); + + // Third tab. + ok(tabs[2].__test_connection_prepared, "Third tab has a connection prepared"); + is( + tabs[2].__test_connection_url, + TEST_URLS[1], + "Third tab has correct host url" + ); + + // Last tab is the previously selected tab. + is( + tabs[3].__test_connection_prepared, + undefined, + "Selected tab should not have a connection prepared" + ); + is( + tabs[3].__test_connection_url, + undefined, + "Selected tab should not have a connection prepared" + ); + + await BrowserTestUtils.closeWindow(newWin); +}); diff --git a/browser/components/sessionstore/test/browser_swapDocShells.js b/browser/components/sessionstore/test/browser_swapDocShells.js new file mode 100644 index 0000000000..047a36c510 --- /dev/null +++ b/browser/components/sessionstore/test/browser_swapDocShells.js @@ -0,0 +1,40 @@ +"use strict"; + +add_task(async function () { + let tab = (gBrowser.selectedTab = BrowserTestUtils.addTab( + gBrowser, + "about:mozilla" + )); + await promiseBrowserLoaded(gBrowser.selectedBrowser); + + let win = gBrowser.replaceTabWithWindow(tab); + await promiseDelayedStartupFinished(win); + await promiseBrowserHasURL(win.gBrowser.browsers[0], "about:mozilla"); + + win.duplicateTabIn(win.gBrowser.selectedTab, "tab"); + await promiseTabRestored(win.gBrowser.tabs[1]); + + let browser = win.gBrowser.browsers[1]; + is(browser.currentURI.spec, "about:mozilla", "tab was duplicated"); + + await BrowserTestUtils.closeWindow(win); +}); + +function promiseDelayedStartupFinished(win) { + return new Promise(resolve => { + whenDelayedStartupFinished(win, resolve); + }); +} + +function promiseBrowserHasURL(browser, url) { + let promise = Promise.resolve(); + + if ( + browser.contentDocument.readyState === "complete" && + browser.currentURI.spec === url + ) { + return promise; + } + + return promise.then(() => promiseBrowserHasURL(browser, url)); +} diff --git a/browser/components/sessionstore/test/browser_switch_remoteness.js b/browser/components/sessionstore/test/browser_switch_remoteness.js new file mode 100644 index 0000000000..daea961335 --- /dev/null +++ b/browser/components/sessionstore/test/browser_switch_remoteness.js @@ -0,0 +1,53 @@ +"use strict"; + +const URL = "http://example.com/browser_switch_remoteness_"; + +function countHistoryEntries(browser, expected) { + return SpecialPowers.spawn(browser, [{ expected }], async function (args) { + let webNavigation = docShell.QueryInterface(Ci.nsIWebNavigation); + let history = webNavigation.sessionHistory; + Assert.equal( + history && history.count, + args.expected, + "correct number of shistory entries" + ); + }); +} + +add_task(async function () { + // Open a new window. + let win = await promiseNewWindowLoaded(); + + // Add a new tab. + let tab = BrowserTestUtils.addTab(win.gBrowser, "about:blank"); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + ok(browser.isRemoteBrowser, "browser is remote"); + + // Get the maximum number of preceding entries to save. + const MAX_BACK = Services.prefs.getIntPref( + "browser.sessionstore.max_serialize_back" + ); + ok(MAX_BACK > -1, "check that the default has a value that caps data"); + + // Load more pages than we would save to disk on a clean shutdown. + for (let i = 0; i < MAX_BACK + 2; i++) { + BrowserTestUtils.loadURIString(browser, URL + i); + await promiseBrowserLoaded(browser); + ok(browser.isRemoteBrowser, "browser is still remote"); + } + + // Check we have the right number of shistory entries. + await countHistoryEntries(browser, MAX_BACK + 2); + + // Load a non-remote page. + BrowserTestUtils.loadURIString(browser, "about:robots"); + await promiseBrowserLoaded(browser); + ok(!browser.isRemoteBrowser, "browser is not remote anymore"); + + // Check that we didn't lose any shistory entries. + await countHistoryEntries(browser, MAX_BACK + 3); + + // Cleanup. + await BrowserTestUtils.closeWindow(win); +}); diff --git a/browser/components/sessionstore/test/browser_tab_label_during_restore.js b/browser/components/sessionstore/test/browser_tab_label_during_restore.js new file mode 100644 index 0000000000..7108990818 --- /dev/null +++ b/browser/components/sessionstore/test/browser_tab_label_during_restore.js @@ -0,0 +1,182 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Test that we don't do unnecessary tab label changes while restoring a tab. + */ + +add_task(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.sessionstore.restore_on_demand", true], + ["browser.sessionstore.restore_tabs_lazily", true], + ], + }); + + const BACKUP_STATE = SessionStore.getBrowserState(); + const REMOTE_URL = "http://www.example.com/"; + const ABOUT_ROBOTS_URI = "about:robots"; + const ABOUT_ROBOTS_TITLE = "Gort! Klaatu barada nikto!"; + const NO_TITLE_URL = "data:text/plain,foo"; + const EMPTY_TAB_TITLE = gBrowser.tabContainer.emptyTabTitle; + + function observeLabelChanges(tab, expectedLabels) { + let seenLabels = [tab.label]; + function TabAttrModifiedListener(event) { + if (event.detail.changed.some(attr => attr == "label")) { + seenLabels.push(tab.label); + } + } + tab.addEventListener("TabAttrModified", TabAttrModifiedListener); + return async () => { + await BrowserTestUtils.waitForCondition( + () => seenLabels.length == expectedLabels.length, + "saw " + seenLabels.length + " TabAttrModified events" + ); + tab.removeEventListener("TabAttrModified", TabAttrModifiedListener); + is( + JSON.stringify(seenLabels), + JSON.stringify(expectedLabels || []), + "observed tab label changes" + ); + }; + } + + info("setting test browser state"); + let browserLoadedPromise = BrowserTestUtils.firstBrowserLoaded(window, false); + await promiseBrowserState({ + windows: [ + { + tabs: [ + { entries: [{ url: REMOTE_URL, triggeringPrincipal_base64 }] }, + { entries: [{ url: ABOUT_ROBOTS_URI, triggeringPrincipal_base64 }] }, + { entries: [{ url: REMOTE_URL, triggeringPrincipal_base64 }] }, + { entries: [{ url: NO_TITLE_URL, triggeringPrincipal_base64 }] }, + { entries: [{ url: "about:blank", triggeringPrincipal_base64 }] }, + ], + }, + ], + }); + let [tab1, tab2, tab3, tab4, tab5] = gBrowser.tabs; + is(gBrowser.selectedTab, tab1, "first tab is selected"); + + await browserLoadedPromise; + const REMOTE_TITLE = tab1.linkedBrowser.contentTitle; + is( + tab1.linkedBrowser.currentURI.spec, + REMOTE_URL, + "correct URL loaded in first tab" + ); + is(typeof REMOTE_TITLE, "string", "content title is a string"); + isnot(REMOTE_TITLE.length, 0, "content title isn't empty"); + isnot(REMOTE_TITLE, REMOTE_URL, "content title is different from the URL"); + is(tab1.label, REMOTE_TITLE, "first tab displays content title"); + ok( + document.title.startsWith(REMOTE_TITLE), + "title bar displays content title" + ); + ok(tab2.hasAttribute("pending"), "second tab is pending"); + ok(tab3.hasAttribute("pending"), "third tab is pending"); + ok(tab4.hasAttribute("pending"), "fourth tab is pending"); + is(tab5.label, EMPTY_TAB_TITLE, "fifth tab dislpays empty tab title"); + + info("selecting the second tab"); + // The fix for bug 1364127 caused about: pages' initial tab titles to show + // their about: URIs until their actual page titles are known, e.g. + // "about:addons" -> "Add-ons Manager". This is bug 1371896. Previously, + // about: pages' initial tab titles were blank until the page title was known. + let finishObservingLabelChanges = observeLabelChanges(tab2, [ + ABOUT_ROBOTS_URI, + ABOUT_ROBOTS_TITLE, + ]); + browserLoadedPromise = BrowserTestUtils.browserLoaded( + tab2.linkedBrowser, + false, + ABOUT_ROBOTS_URI + ); + gBrowser.selectedTab = tab2; + await browserLoadedPromise; + ok(!tab2.hasAttribute("pending"), "second tab isn't pending anymore"); + await finishObservingLabelChanges(); + ok( + document.title.startsWith(ABOUT_ROBOTS_TITLE), + "title bar displays content title" + ); + + info("selecting the third tab"); + finishObservingLabelChanges = observeLabelChanges(tab3, [ + "example.com/", + REMOTE_TITLE, + ]); + browserLoadedPromise = BrowserTestUtils.browserLoaded( + tab3.linkedBrowser, + false, + REMOTE_URL + ); + gBrowser.selectedTab = tab3; + await browserLoadedPromise; + ok(!tab3.hasAttribute("pending"), "third tab isn't pending anymore"); + await finishObservingLabelChanges(); + ok( + document.title.startsWith(REMOTE_TITLE), + "title bar displays content title" + ); + + info("selecting the fourth tab"); + finishObservingLabelChanges = observeLabelChanges(tab4, [NO_TITLE_URL]); + browserLoadedPromise = BrowserTestUtils.browserLoaded( + tab4.linkedBrowser, + false, + NO_TITLE_URL + ); + gBrowser.selectedTab = tab4; + await browserLoadedPromise; + ok(!tab4.hasAttribute("pending"), "fourth tab isn't pending anymore"); + await finishObservingLabelChanges(); + is( + document.title, + document.getElementById("bundle_brand").getString("brandFullName"), + "title bar doesn't display content title since page doesn't have one" + ); + + info("restoring the modified browser state"); + gBrowser.selectedTab = tab3; + await TabStateFlusher.flushWindow(window); + await promiseBrowserState(SessionStore.getBrowserState()); + [tab1, tab2, tab3, tab4, tab5] = gBrowser.tabs; + is(tab3, gBrowser.selectedTab, "third tab is selected after restoring"); + ok( + document.title.startsWith(REMOTE_TITLE), + "title bar displays content title" + ); + ok(tab1.hasAttribute("pending"), "first tab is pending after restoring"); + ok(tab2.hasAttribute("pending"), "second tab is pending after restoring"); + is(tab2.label, ABOUT_ROBOTS_TITLE, "second tab displays content title"); + ok(!tab3.hasAttribute("pending"), "third tab is not pending after restoring"); + is( + tab3.label, + REMOTE_TITLE, + "third tab displays content title in pending state" + ); + ok(tab4.hasAttribute("pending"), "fourth tab is pending after restoring"); + is(tab4.label, NO_TITLE_URL, "fourth tab displays URL"); + is(tab5.label, EMPTY_TAB_TITLE, "fifth tab still displays empty tab title"); + + info("selecting the first tab"); + finishObservingLabelChanges = observeLabelChanges(tab1, [REMOTE_TITLE]); + let tabContentRestored = TestUtils.topicObserved( + "sessionstore-debug-tab-restored" + ); + gBrowser.selectedTab = tab1; + ok( + document.title.startsWith(REMOTE_TITLE), + "title bar displays content title" + ); + await tabContentRestored; + ok(!tab1.hasAttribute("pending"), "first tab isn't pending anymore"); + await finishObservingLabelChanges(); + + await promiseBrowserState(BACKUP_STATE); +}); diff --git a/browser/components/sessionstore/test/browser_tabicon_after_bg_tab_crash.js b/browser/components/sessionstore/test/browser_tabicon_after_bg_tab_crash.js new file mode 100644 index 0000000000..5eadca7a24 --- /dev/null +++ b/browser/components/sessionstore/test/browser_tabicon_after_bg_tab_crash.js @@ -0,0 +1,52 @@ +"use strict"; + +const FAVICON = + "data:image/gif;base64,R0lGODlhCwALAIAAAAAA3pn/ZiH5BAEAAAEALAAAAAALAAsAAAIUhA+hkcuO4lmNVindo7qyrIXiGBYAOw=="; +const PAGE_URL = `data:text/html, + + + + + + Favicon! + +`; + +/** + * Tests that if a background tab crashes that it doesn't + * lose the favicon in the tab. + */ +add_task(async function test_tabicon_after_bg_tab_crash() { + let originalTab = gBrowser.selectedTab; + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: PAGE_URL, + }, + async function (browser) { + // Because there is debounce logic in ContentLinkHandler.jsm to reduce the + // favicon loads, we have to wait some time before checking that icon was + // stored properly. + await BrowserTestUtils.waitForCondition( + () => { + return gBrowser.getIcon() != null; + }, + "wait for favicon load to finish", + 100, + 5 + ); + Assert.equal(browser.mIconURL, FAVICON, "Favicon is correctly set."); + await BrowserTestUtils.switchTab(gBrowser, originalTab); + await BrowserTestUtils.crashFrame( + browser, + false /* shouldShowTabCrashPage */ + ); + Assert.equal( + browser.mIconURL, + FAVICON, + "Favicon is still set after crash." + ); + } + ); +}); diff --git a/browser/components/sessionstore/test/browser_tabs_in_urlbar.js b/browser/components/sessionstore/test/browser_tabs_in_urlbar.js new file mode 100644 index 0000000000..b82ba24a2a --- /dev/null +++ b/browser/components/sessionstore/test/browser_tabs_in_urlbar.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/. */ + +/** + * Tests that tabs which aren't displayed yet (i.e. need to be reloaded) are + * still displayed in the address bar results. + */ + +const RESTRICT_TOKEN_OPENPAGE = "%"; + +const { PlacesTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PlacesTestUtils.sys.mjs" +); + +const { UrlbarProviderOpenTabs } = ChromeUtils.importESModule( + "resource:///modules/UrlbarProviderOpenTabs.sys.mjs" +); + +const { UrlbarTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/UrlbarTestUtils.sys.mjs" +); + +var stateBackup = ss.getBrowserState(); + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + // Set the pref to true so we know exactly how many tabs should be restoring at + // any given time. This guarantees that a finishing load won't start another. + ["browser.sessionstore.restore_on_demand", true], + // Don't restore tabs lazily. + ["browser.sessionstore.restore_tabs_lazily", false], + ], + }); + + registerCleanupFunction(() => { + ss.setBrowserState(stateBackup); + }); + + info("Waiting for the Places DB to be initialized"); + await PlacesUtils.promiseLargeCacheDBConnection(); + await UrlbarProviderOpenTabs.promiseDBPopulated; +}); + +add_task(async function test_unrestored_tabs_listed() { + const state = { + windows: [ + { + tabs: [ + { + entries: [ + { url: "http://example.org/#1", triggeringPrincipal_base64 }, + ], + }, + { + entries: [ + { url: "http://example.org/#2", triggeringPrincipal_base64 }, + ], + }, + { + entries: [ + { url: "http://example.org/#3", triggeringPrincipal_base64 }, + ], + }, + { + entries: [ + { url: "http://example.org/#4", triggeringPrincipal_base64 }, + ], + }, + ], + selected: 1, + }, + ], + }; + + const tabsForEnsure = new Set(); + state.windows[0].tabs.forEach(function (tab) { + tabsForEnsure.add(tab.entries[0].url); + }); + + let tabsRestoring = 0; + let tabsRestored = 0; + + await new Promise(resolve => { + function handleEvent(aEvent) { + if (aEvent.type == "SSTabRestoring") { + tabsRestoring++; + } else { + tabsRestored++; + } + + if (tabsRestoring < state.windows[0].tabs.length || tabsRestored < 1) { + return; + } + + gBrowser.tabContainer.removeEventListener( + "SSTabRestoring", + handleEvent, + true + ); + gBrowser.tabContainer.removeEventListener( + "SSTabRestored", + handleEvent, + true + ); + executeSoon(resolve); + } + + // currentURI is set before SSTabRestoring is fired, so we can sucessfully check + // after that has fired for all tabs. Since 1 tab will be restored though, we + // also need to wait for 1 SSTabRestored since currentURI will be set, unset, then set. + gBrowser.tabContainer.addEventListener("SSTabRestoring", handleEvent, true); + gBrowser.tabContainer.addEventListener("SSTabRestored", handleEvent, true); + ss.setBrowserState(JSON.stringify(state)); + }); + + // Ensure any database statements started by UrlbarProviderOpenTabs are + // complete before continuing. + await PlacesTestUtils.promiseAsyncUpdates(); + + // Remove the current tab from tabsForEnsure, because switch to tab doesn't + // suggest it. + tabsForEnsure.delete(gBrowser.currentURI.spec); + info("Searching open pages."); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + waitForFocus, + value: RESTRICT_TOKEN_OPENPAGE, + }); + const total = UrlbarTestUtils.getResultCount(window); + info(`Found ${total} matches`); + + // Check to see the expected uris and titles match up (in any order) + for (let i = 0; i < total; i++) { + const result = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + if (result.heuristic) { + info("Skip heuristic match"); + continue; + } + const url = result.url; + Assert.ok( + tabsForEnsure.has(url), + `Should have the found result '${url}' in the expected list of entries` + ); + // Remove the found entry from expected results. + tabsForEnsure.delete(url); + } + // Make sure there is no reported open page that is not open. + Assert.equal(tabsForEnsure.size, 0, "Should have found all the tabs"); +}); diff --git a/browser/components/sessionstore/test/browser_undoCloseById.js b/browser/components/sessionstore/test/browser_undoCloseById.js new file mode 100644 index 0000000000..924a25c770 --- /dev/null +++ b/browser/components/sessionstore/test/browser_undoCloseById.js @@ -0,0 +1,174 @@ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ +"use strict"; + +/** + * This test is for the undoCloseById function. + */ + +async function openWindow(url) { + let win = await promiseNewWindowLoaded(); + let flags = Ci.nsIWebNavigation.LOAD_FLAGS_REPLACE_HISTORY; + BrowserTestUtils.loadURIString(win.gBrowser.selectedBrowser, url, { flags }); + await promiseBrowserLoaded(win.gBrowser.selectedBrowser, true, url); + return win; +} + +async function closeWindow(win) { + await BrowserTestUtils.closeWindow(win); + // Wait 20 ms to allow SessionStorage a chance to register the closed window. + await new Promise(resolve => setTimeout(resolve, 20)); +} + +add_task(async function test_undoCloseById() { + // Clear the lists of closed windows and tabs. + forgetClosedWindows(); + while (SessionStore.getClosedTabCountForWindow(window)) { + SessionStore.forgetClosedTab(window, 0); + } + + // Open a new window. + let win = await openWindow("about:robots"); + + // Open and close a tab. + await openAndCloseTab(win, "about:mozilla"); + is( + SessionStore.lastClosedObjectType, + "tab", + "The last closed object is a tab" + ); + + // Record the first closedId created. + let initialClosedId = SessionStore.getClosedTabDataForWindow(win)[0].closedId; + + // Open and close another window. + let win2 = await openWindow("about:mozilla"); + await closeWindow(win2); // closedId == initialClosedId + 1 + is( + SessionStore.lastClosedObjectType, + "window", + "The last closed object is a window" + ); + + // Open and close another tab in the first window. + await openAndCloseTab(win, "about:robots"); // closedId == initialClosedId + 2 + is( + SessionStore.lastClosedObjectType, + "tab", + "The last closed object is a tab" + ); + + // Undo closing the second tab. + let tab = SessionStore.undoCloseById(initialClosedId + 2); + await promiseBrowserLoaded(tab.linkedBrowser); + is( + tab.linkedBrowser.currentURI.spec, + "about:robots", + "The expected tab was re-opened" + ); + + let notTab = SessionStore.undoCloseById(initialClosedId + 2); + is(notTab, undefined, "Re-opened tab cannot be unClosed again by closedId"); + + // Now the last closed object should be a window again. + is( + SessionStore.lastClosedObjectType, + "window", + "The last closed object is a window" + ); + + // Undo closing the first tab. + let tab2 = SessionStore.undoCloseById(initialClosedId); + await promiseBrowserLoaded(tab2.linkedBrowser); + is( + tab2.linkedBrowser.currentURI.spec, + "about:mozilla", + "The expected tab was re-opened" + ); + + // Close the two tabs we re-opened. + await promiseRemoveTabAndSessionState(tab); // closedId == initialClosedId + 3 + is( + SessionStore.lastClosedObjectType, + "tab", + "The last closed object is a tab" + ); + await promiseRemoveTabAndSessionState(tab2); // closedId == initialClosedId + 4 + is( + SessionStore.lastClosedObjectType, + "tab", + "The last closed object is a tab" + ); + + // Open another new window. + let win3 = await openWindow("about:mozilla"); + + // Close both windows. + await closeWindow(win); // closedId == initialClosedId + 5 + is( + SessionStore.lastClosedObjectType, + "window", + "The last closed object is a window" + ); + await closeWindow(win3); // closedId == initialClosedId + 6 + is( + SessionStore.lastClosedObjectType, + "window", + "The last closed object is a window" + ); + + // Undo closing the second window. + win = SessionStore.undoCloseById(initialClosedId + 6); + await BrowserTestUtils.waitForEvent(win, "load"); + + // Make sure we wait until this window is restored. + await BrowserTestUtils.waitForEvent( + win.gBrowser.tabContainer, + "SSTabRestored" + ); + + is( + win.gBrowser.selectedBrowser.currentURI.spec, + "about:mozilla", + "The expected window was re-opened" + ); + + let notWin = SessionStore.undoCloseById(initialClosedId + 6); + is( + notWin, + undefined, + "Re-opened window cannot be unClosed again by closedId" + ); + + // Close the window again. + await closeWindow(win); + is( + SessionStore.lastClosedObjectType, + "window", + "The last closed object is a window" + ); + + // Undo closing the first window. + win = SessionStore.undoCloseById(initialClosedId + 5); + + await BrowserTestUtils.waitForEvent(win, "load"); + + // Make sure we wait until this window is restored. + await BrowserTestUtils.waitForEvent( + win.gBrowser.tabContainer, + "SSTabRestored" + ); + + is( + win.gBrowser.selectedBrowser.currentURI.spec, + "about:robots", + "The expected window was re-opened" + ); + + // Close the window again. + await closeWindow(win); + is( + SessionStore.lastClosedObjectType, + "window", + "The last closed object is a window" + ); +}); diff --git a/browser/components/sessionstore/test/browser_unrestored_crashedTabs.js b/browser/components/sessionstore/test/browser_unrestored_crashedTabs.js new file mode 100644 index 0000000000..51e54af12a --- /dev/null +++ b/browser/components/sessionstore/test/browser_unrestored_crashedTabs.js @@ -0,0 +1,73 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that if we have tabs that are still in the "click to + * restore" state, that if their browsers crash, that we don't + * show the crashed state for those tabs (since selecting them + * should restore them anyway). + */ + +const PREF = "browser.sessionstore.restore_on_demand"; +const PAGE = + "data:text/html,A%20regular,%20everyday,%20normal%20page."; + +add_task(async function test() { + await pushPrefs([PREF, true]); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: PAGE, + }, + async function (browser) { + await TabStateFlusher.flush(browser); + + // We'll create a second "pending" tab. This is the one we'll + // ensure doesn't go to about:tabcrashed. We start it non-remote + // since this is how SessionStore creates all browsers before + // they are restored. + let unrestoredTab = BrowserTestUtils.addTab(gBrowser, "about:blank", { + skipAnimation: true, + forceNotRemote: true, + }); + + let state = { + entries: [{ url: PAGE, triggeringPrincipal_base64 }], + }; + + ss.setTabState(unrestoredTab, JSON.stringify(state)); + + ok(!unrestoredTab.hasAttribute("crashed"), "tab is not crashed"); + ok(unrestoredTab.hasAttribute("pending"), "tab is pending"); + + // Now crash the selected browser. + await BrowserTestUtils.crashFrame(browser); + + ok(!unrestoredTab.hasAttribute("crashed"), "tab is still not crashed"); + ok(unrestoredTab.hasAttribute("pending"), "tab is still pending"); + + // Selecting the tab should now restore it. + gBrowser.selectedTab = unrestoredTab; + await promiseTabRestored(unrestoredTab); + + ok(!unrestoredTab.hasAttribute("crashed"), "tab is still not crashed"); + ok(!unrestoredTab.hasAttribute("pending"), "tab is no longer pending"); + + // The original tab should still be crashed + let originalTab = gBrowser.getTabForBrowser(browser); + ok(originalTab.hasAttribute("crashed"), "original tab is crashed"); + ok(!originalTab.isRemoteBrowser, "Should not be remote"); + + // We'd better be able to restore it still. + gBrowser.selectedTab = originalTab; + SessionStore.reviveCrashedTab(originalTab); + await promiseTabRestored(originalTab); + + // Clean up. + BrowserTestUtils.removeTab(unrestoredTab); + } + ); +}); diff --git a/browser/components/sessionstore/test/browser_upgrade_backup.js b/browser/components/sessionstore/test/browser_upgrade_backup.js new file mode 100644 index 0000000000..fa0e34d421 --- /dev/null +++ b/browser/components/sessionstore/test/browser_upgrade_backup.js @@ -0,0 +1,158 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { Preferences } = ChromeUtils.importESModule( + "resource://gre/modules/Preferences.sys.mjs" +); + +const Paths = SessionFile.Paths; +const PREF_UPGRADE = "browser.sessionstore.upgradeBackup.latestBuildID"; +const PREF_MAX_UPGRADE_BACKUPS = + "browser.sessionstore.upgradeBackup.maxUpgradeBackups"; + +/** + * Prepares tests by retrieving the current platform's build ID, clearing the + * build where the last backup was created and creating arbitrary JSON data + * for a new backup. + */ +function prepareTest() { + let result = {}; + + result.buildID = Services.appinfo.platformBuildID; + Services.prefs.setCharPref(PREF_UPGRADE, ""); + result.contents = { + "browser_upgrade_backup.js": Math.random(), + }; + + return result; +} + +/** + * Retrieves all upgrade backups and returns them in an array. + */ +async function getUpgradeBackups() { + let children = await IOUtils.getChildren(Paths.backups); + + return children.filter(path => path.startsWith(Paths.upgradeBackupPrefix)); +} + +add_setup(async function () { + // Wait until initialization is complete + await SessionStore.promiseInitialized; +}); + +add_task(async function test_upgrade_backup() { + let test = prepareTest(); + info("Let's check if we create an upgrade backup"); + await SessionFile.wipe(); + await IOUtils.writeJSON(Paths.clean, test.contents, { + compress: true, + }); + info("Call `SessionFile.read()` to set state to 'clean'"); + await SessionFile.read(); + await SessionFile.write(""); // First call to write() triggers the backup + + Assert.equal( + Services.prefs.getCharPref(PREF_UPGRADE), + test.buildID, + "upgrade backup should be set" + ); + + Assert.ok( + await IOUtils.exists(Paths.upgradeBackup), + "upgrade backup file has been created" + ); + + let data = await IOUtils.readJSON(Paths.upgradeBackup, { decompress: true }); + Assert.deepEqual( + test.contents, + data, + "upgrade backup contains the expected contents" + ); + + info("Let's check that we don't overwrite this upgrade backup"); + let newContents = { + "something else entirely": Math.random(), + }; + await IOUtils.writeJSON(Paths.clean, newContents, { + compress: true, + }); + await SessionFile.write(""); // Next call to write() shouldn't trigger the backup + data = await IOUtils.readJSON(Paths.upgradeBackup, { decompress: true }); + Assert.deepEqual(test.contents, data, "upgrade backup hasn't changed"); +}); + +add_task(async function test_upgrade_backup_removal() { + let test = prepareTest(); + let maxUpgradeBackups = Preferences.get(PREF_MAX_UPGRADE_BACKUPS, 3); + info("Let's see if we remove backups if there are too many"); + await SessionFile.wipe(); + await IOUtils.writeJSON(Paths.clean, test.contents, { + compress: true, + }); + info("Call `SessionFile.read()` to set state to 'clean'"); + await SessionFile.read(); + + // create dummy backups + await IOUtils.writeUTF8(Paths.upgradeBackupPrefix + "20080101010101", "", { + compress: true, + }); + await IOUtils.writeUTF8(Paths.upgradeBackupPrefix + "20090101010101", "", { + compress: true, + }); + await IOUtils.writeUTF8(Paths.upgradeBackupPrefix + "20100101010101", "", { + compress: true, + }); + await IOUtils.writeUTF8(Paths.upgradeBackupPrefix + "20110101010101", "", { + compress: true, + }); + await IOUtils.writeUTF8(Paths.upgradeBackupPrefix + "20120101010101", "", { + compress: true, + }); + await IOUtils.writeUTF8(Paths.upgradeBackupPrefix + "20130101010101", "", { + compress: true, + }); + + // get currently existing backups + let backups = await getUpgradeBackups(); + + info("Write the session to disk and perform a backup"); + await SessionFile.write(""); // First call to write() triggers the backup and the cleanup + + // a new backup should have been created (and still exist) + is( + Services.prefs.getCharPref(PREF_UPGRADE), + test.buildID, + "upgrade backup should be set" + ); + Assert.ok( + await IOUtils.exists(Paths.upgradeBackup), + "upgrade backup file has been created" + ); + + // get currently existing backups and check their count + let newBackups = await getUpgradeBackups(); + is( + newBackups.length, + maxUpgradeBackups, + "expected number of backups are present after removing old backups" + ); + + // find all backups that were created during the last call to `SessionFile.write("");` + // ie, filter out all the backups that have already been present before the call + newBackups = newBackups.filter(function (backup) { + return !backups.includes(backup); + }); + + // check that exactly one new backup was created + is(newBackups.length, 1, "one new backup was created that was not removed"); + + await SessionFile.write(""); // Second call to write() should not trigger anything + + backups = await getUpgradeBackups(); + is( + backups.length, + maxUpgradeBackups, + "second call to SessionFile.write() didn't create or remove more backups" + ); +}); diff --git a/browser/components/sessionstore/test/browser_urlbarSearchMode.js b/browser/components/sessionstore/test/browser_urlbarSearchMode.js new file mode 100644 index 0000000000..052fcf355c --- /dev/null +++ b/browser/components/sessionstore/test/browser_urlbarSearchMode.js @@ -0,0 +1,57 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * This test makes sure that the urlbar's search mode is correctly preserved. + */ + +ChromeUtils.defineESModuleGetters(this, { + UrlbarUtils: "resource:///modules/UrlbarUtils.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(this, "UrlbarTestUtils", () => { + const { UrlbarTestUtils: module } = ChromeUtils.importESModule( + "resource://testing-common/UrlbarTestUtils.sys.mjs" + ); + module.init(this); + return module; +}); + +add_task(async function test() { + // Open the urlbar view and enter search mode. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "test", + }); + await UrlbarTestUtils.enterSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + }); + + // The search mode should be in the tab state. + let state = JSON.parse(ss.getTabState(gBrowser.selectedTab)); + Assert.ok( + "searchMode" in state, + "state.searchMode is present after entering search mode" + ); + Assert.deepEqual( + state.searchMode, + { + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + entry: "oneoff", + isPreview: false, + }, + "state.searchMode is correct" + ); + + // Exit search mode. + await UrlbarTestUtils.exitSearchMode(window); + + // The search mode should not be in the tab state. + let newState = JSON.parse(ss.getTabState(gBrowser.selectedTab)); + Assert.ok( + !newState.searchMode, + "state.searchMode is not present after exiting search mode" + ); + + await UrlbarTestUtils.promisePopupClose(window); +}); diff --git a/browser/components/sessionstore/test/browser_userTyped_restored_after_discard.js b/browser/components/sessionstore/test/browser_userTyped_restored_after_discard.js new file mode 100644 index 0000000000..171197a743 --- /dev/null +++ b/browser/components/sessionstore/test/browser_userTyped_restored_after_discard.js @@ -0,0 +1,47 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_task(async function testDiscardWithNotLoadedUserTypedValue() { + let tab1 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "https://example.com" + ); + + // Make sure we flushed the state at least once (otherwise the fix + // for Bug 1422588 would make SessionStore.resetBrowserToLazyState + // to still store the user typed value into the tab state cache + // even when the user typed value was not yet being loading when + // the tab got discarded). + await TabStateFlusher.flush(tab1.linkedBrowser); + + tab1.linkedBrowser.userTypedValue = "mockUserTypedValue"; + + let tab2 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:robots" + ); + + let waitForTabDiscarded = BrowserTestUtils.waitForEvent( + tab1, + "TabBrowserDiscarded" + ); + gBrowser.discardBrowser(tab1); + await waitForTabDiscarded; + + const promiseTabLoaded = BrowserTestUtils.browserLoaded( + tab1.linkedBrowser, + false, + "https://example.com/" + ); + await BrowserTestUtils.switchTab(gBrowser, tab1); + info("Wait for the restored tab to load https://example.com"); + await promiseTabLoaded; + is( + tab1.linkedBrowser.currentURI.spec, + "https://example.com/", + "Restored discarded tab has loaded the expected url" + ); + + BrowserTestUtils.removeTab(tab2); + BrowserTestUtils.removeTab(tab1); +}); diff --git a/browser/components/sessionstore/test/browser_windowRestore_perwindowpb.js b/browser/components/sessionstore/test/browser_windowRestore_perwindowpb.js new file mode 100644 index 0000000000..73568cb348 --- /dev/null +++ b/browser/components/sessionstore/test/browser_windowRestore_perwindowpb.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/. */ + +// This test checks that closed private windows can't be restored + +function test() { + waitForExplicitFinish(); + + // Purging the list of closed windows + forgetClosedWindows(); + + // Load a private window, then close it + // and verify it doesn't get remembered for restoring + whenNewWindowLoaded({ private: true }, function (win) { + info("The private window got loaded"); + win.addEventListener( + "SSWindowClosing", + function () { + executeSoon(function () { + is( + ss.getClosedWindowCount(), + 0, + "The private window should not have been stored" + ); + }); + }, + { once: true } + ); + BrowserTestUtils.closeWindow(win).then(finish); + }); +} diff --git a/browser/components/sessionstore/test/browser_windowStateContainer.js b/browser/components/sessionstore/test/browser_windowStateContainer.js new file mode 100644 index 0000000000..f0d6f42d39 --- /dev/null +++ b/browser/components/sessionstore/test/browser_windowStateContainer.js @@ -0,0 +1,176 @@ +"use strict"; + +requestLongerTimeout(2); + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["dom.ipc.processCount", 1]], + }); +}); + +function promiseTabsRestored(win, nExpected) { + return new Promise(resolve => { + let nReceived = 0; + function handler(event) { + if (++nReceived === nExpected) { + win.gBrowser.tabContainer.removeEventListener( + "SSTabRestored", + handler, + true + ); + resolve(); + } + } + win.gBrowser.tabContainer.addEventListener("SSTabRestored", handler, true); + }); +} + +add_task(async function () { + let win = await BrowserTestUtils.openNewBrowserWindow(); + + // Create 4 tabs with different userContextId. + for (let userContextId = 1; userContextId < 5; userContextId++) { + let tab = BrowserTestUtils.addTab(win.gBrowser, "http://example.com/", { + userContextId, + }); + await promiseBrowserLoaded(tab.linkedBrowser); + await TabStateFlusher.flush(tab.linkedBrowser); + } + + // Move the default tab of window to the end. + // We want the 1st tab to have non-default userContextId, so later when we + // restore into win2 we can test restore into an existing tab with different + // userContextId. + win.gBrowser.moveTabTo(win.gBrowser.tabs[0], win.gBrowser.tabs.length - 1); + + let winState = ss.getWindowState(win); + + for (let i = 0; i < 4; i++) { + Assert.equal( + winState.windows[0].tabs[i].userContextId, + i + 1, + "1st Window: tabs[" + i + "].userContextId should exist." + ); + } + + let win2 = await BrowserTestUtils.openNewBrowserWindow(); + + // Create tabs with different userContextId, but this time we create them with + // fewer tabs and with different order with win. + for (let userContextId = 3; userContextId > 0; userContextId--) { + let tab = BrowserTestUtils.addTab(win2.gBrowser, "http://example.com/", { + userContextId, + }); + await promiseBrowserLoaded(tab.linkedBrowser); + await TabStateFlusher.flush(tab.linkedBrowser); + } + + let tabsRestored = promiseTabsRestored(win2, 5); + await setWindowState(win2, winState, true); + await tabsRestored; + + for (let i = 0; i < 4; i++) { + let browser = win2.gBrowser.tabs[i].linkedBrowser; + await ContentTask.spawn( + browser, + { expectedId: i + 1 }, + async function (args) { + Assert.equal( + docShell.getOriginAttributes().userContextId, + args.expectedId, + "The docShell has the correct userContextId" + ); + + Assert.equal( + content.document.nodePrincipal.originAttributes.userContextId, + args.expectedId, + "The document has the correct userContextId" + ); + } + ); + } + + // Test the last tab, which doesn't have userContextId. + let browser = win2.gBrowser.tabs[4].linkedBrowser; + await SpecialPowers.spawn( + browser, + [{ expectedId: 0 }], + async function (args) { + Assert.equal( + docShell.getOriginAttributes().userContextId, + args.expectedId, + "The docShell has the correct userContextId" + ); + + Assert.equal( + content.document.nodePrincipal.originAttributes.userContextId, + args.expectedId, + "The document has the correct userContextId" + ); + } + ); + + await BrowserTestUtils.closeWindow(win); + await BrowserTestUtils.closeWindow(win2); +}); + +add_task(async function () { + let win = await BrowserTestUtils.openNewBrowserWindow(); + await TabStateFlusher.flush(win.gBrowser.selectedBrowser); + + let tab = BrowserTestUtils.addTab(win.gBrowser, "http://example.com/", { + userContextId: 1, + }); + await promiseBrowserLoaded(tab.linkedBrowser); + await TabStateFlusher.flush(tab.linkedBrowser); + + // win should have 1 default tab, and 1 container tab. + Assert.equal(win.gBrowser.tabs.length, 2, "win should have 2 tabs"); + + let winState = ss.getWindowState(win); + + for (let i = 0; i < 2; i++) { + Assert.equal( + winState.windows[0].tabs[i].userContextId, + i, + "1st Window: tabs[" + i + "].userContextId should be " + i + ); + } + + let win2 = await BrowserTestUtils.openNewBrowserWindow(); + + let tab2 = BrowserTestUtils.addTab(win2.gBrowser, "http://example.com/", { + userContextId: 1, + }); + await promiseBrowserLoaded(tab2.linkedBrowser); + await TabStateFlusher.flush(tab2.linkedBrowser); + + // Move the first normal tab to end, so the first tab of win2 will be a + // container tab. + win2.gBrowser.moveTabTo(win2.gBrowser.tabs[0], win2.gBrowser.tabs.length - 1); + await TabStateFlusher.flush(win2.gBrowser.tabs[0].linkedBrowser); + + let tabsRestored = promiseTabsRestored(win2, 2); + await setWindowState(win2, winState, true); + await tabsRestored; + + for (let i = 0; i < 2; i++) { + let browser = win2.gBrowser.tabs[i].linkedBrowser; + await ContentTask.spawn(browser, { expectedId: i }, async function (args) { + Assert.equal( + docShell.getOriginAttributes().userContextId, + args.expectedId, + "The docShell has the correct userContextId" + ); + + Assert.equal( + content.document.nodePrincipal.originAttributes.userContextId, + args.expectedId, + "The document has the correct userContextId" + ); + }); + } + + await BrowserTestUtils.closeWindow(win); + await BrowserTestUtils.closeWindow(win2); +}); diff --git a/browser/components/sessionstore/test/coopHeaderCommon.sjs b/browser/components/sessionstore/test/coopHeaderCommon.sjs new file mode 100644 index 0000000000..5c4801718c --- /dev/null +++ b/browser/components/sessionstore/test/coopHeaderCommon.sjs @@ -0,0 +1,31 @@ +function handleRequest(request, response) { + Cu.importGlobalProperties(["URLSearchParams"]); + let { NetUtil } = ChromeUtils.import("resource://gre/modules/NetUtil.jsm"); + let query = new URLSearchParams(request.queryString); + + response.setHeader("Cross-Origin-Opener-Policy", "same-origin", false); + response.setHeader("Cross-Origin-Embedder-Policy", "require-corp", false); + + var fileRoot = query.get("fileRoot"); + + // Get the desired file + var file; + getObjectState("SERVER_ROOT", function (serverRoot) { + file = serverRoot.getFile(fileRoot); + }); + + // Set up the file streams to read in the file as UTF-8 + let fstream = Cc["@mozilla.org/network/file-input-stream;1"].createInstance( + Ci.nsIFileInputStream + ); + + fstream.init(file, -1, 0, 0); + + // Read the file + let available = fstream.available(); + let data = + available > 0 ? NetUtil.readInputStreamToString(fstream, available) : ""; + fstream.close(); + + response.write(data); +} diff --git a/browser/components/sessionstore/test/coop_coep.html b/browser/components/sessionstore/test/coop_coep.html new file mode 100644 index 0000000000..9fe6f7a03e --- /dev/null +++ b/browser/components/sessionstore/test/coop_coep.html @@ -0,0 +1,6 @@ + + + + + + diff --git a/browser/components/sessionstore/test/coop_coep.html^headers^ b/browser/components/sessionstore/test/coop_coep.html^headers^ new file mode 100644 index 0000000000..5f8621ef83 --- /dev/null +++ b/browser/components/sessionstore/test/coop_coep.html^headers^ @@ -0,0 +1,2 @@ +Cross-Origin-Embedder-Policy: require-corp +Cross-Origin-Opener-Policy: same-origin diff --git a/browser/components/sessionstore/test/empty.html b/browser/components/sessionstore/test/empty.html new file mode 100644 index 0000000000..ba0056bc32 --- /dev/null +++ b/browser/components/sessionstore/test/empty.html @@ -0,0 +1,6 @@ + + + + + + diff --git a/browser/components/sessionstore/test/file_async_duplicate_tab.html b/browser/components/sessionstore/test/file_async_duplicate_tab.html new file mode 100644 index 0000000000..17f1c080ea --- /dev/null +++ b/browser/components/sessionstore/test/file_async_duplicate_tab.html @@ -0,0 +1 @@ +clickme diff --git a/browser/components/sessionstore/test/file_async_flushes.html b/browser/components/sessionstore/test/file_async_flushes.html new file mode 100644 index 0000000000..17f1c080ea --- /dev/null +++ b/browser/components/sessionstore/test/file_async_flushes.html @@ -0,0 +1 @@ +clickme diff --git a/browser/components/sessionstore/test/file_formdata_password.html b/browser/components/sessionstore/test/file_formdata_password.html new file mode 100644 index 0000000000..0f072c31e1 --- /dev/null +++ b/browser/components/sessionstore/test/file_formdata_password.html @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + diff --git a/browser/components/sessionstore/test/file_sessionHistory_hashchange.html b/browser/components/sessionstore/test/file_sessionHistory_hashchange.html new file mode 100644 index 0000000000..4b64fc180a --- /dev/null +++ b/browser/components/sessionstore/test/file_sessionHistory_hashchange.html @@ -0,0 +1 @@ +clickme diff --git a/browser/components/sessionstore/test/head.js b/browser/components/sessionstore/test/head.js new file mode 100644 index 0000000000..d73c098eea --- /dev/null +++ b/browser/components/sessionstore/test/head.js @@ -0,0 +1,782 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 triggeringPrincipal_base64 = E10SUtils.SERIALIZED_SYSTEMPRINCIPAL; + +const TAB_STATE_NEEDS_RESTORE = 1; +const TAB_STATE_RESTORING = 2; + +const ROOT = getRootDirectory(gTestPath); +const HTTPROOT = ROOT.replace( + "chrome://mochitests/content/", + "http://example.com/" +); +const HTTPSROOT = ROOT.replace( + "chrome://mochitests/content/", + "https://example.com/" +); + +const { SessionSaver } = ChromeUtils.importESModule( + "resource:///modules/sessionstore/SessionSaver.sys.mjs" +); +const { SessionFile } = ChromeUtils.importESModule( + "resource:///modules/sessionstore/SessionFile.sys.mjs" +); +const { TabState } = ChromeUtils.importESModule( + "resource:///modules/sessionstore/TabState.sys.mjs" +); +const { TabStateFlusher } = ChromeUtils.importESModule( + "resource:///modules/sessionstore/TabStateFlusher.sys.mjs" +); +const ss = SessionStore; + +// Some tests here assume that all restored tabs are loaded without waiting for +// the user to bring them to the foreground. We ensure this by resetting the +// related preference (see the "firefox.js" defaults file for details). +Services.prefs.setBoolPref("browser.sessionstore.restore_on_demand", false); +registerCleanupFunction(function () { + Services.prefs.clearUserPref("browser.sessionstore.restore_on_demand"); +}); + +// Obtain access to internals +Services.prefs.setBoolPref("browser.sessionstore.debug", true); +registerCleanupFunction(function () { + Services.prefs.clearUserPref("browser.sessionstore.debug"); +}); + +// This kicks off the search service used on about:home and allows the +// session restore tests to be run standalone without triggering errors. +Cc["@mozilla.org/browser/clh;1"].getService(Ci.nsIBrowserHandler).defaultArgs; + +function provideWindow(aCallback, aURL, aFeatures) { + function callbackSoon(aWindow) { + executeSoon(function executeCallbackSoon() { + aCallback(aWindow); + }); + } + + let win = openDialog( + AppConstants.BROWSER_CHROME_URL, + "", + aFeatures || "chrome,all,dialog=no", + aURL || "about:blank" + ); + whenWindowLoaded(win, function onWindowLoaded(aWin) { + if (!aURL) { + info("Loaded a blank window."); + callbackSoon(aWin); + return; + } + + aWin.gBrowser.selectedBrowser.addEventListener( + "load", + function () { + callbackSoon(aWin); + }, + { capture: true, once: true } + ); + }); +} + +// This assumes that tests will at least have some state/entries +function waitForBrowserState(aState, aSetStateCallback) { + if (typeof aState == "string") { + aState = JSON.parse(aState); + } + if (typeof aState != "object") { + throw new TypeError( + "Argument must be an object or a JSON representation of an object" + ); + } + let windows = [window]; + let tabsRestored = 0; + let expectedTabsRestored = 0; + let expectedWindows = aState.windows.length; + let windowsOpen = 1; + let listening = false; + let windowObserving = false; + let restoreHiddenTabs = Services.prefs.getBoolPref( + "browser.sessionstore.restore_hidden_tabs" + ); + // This should match the |restoreTabsLazily| value that + // SessionStore.restoreWindow() uses. + let restoreTabsLazily = + Services.prefs.getBoolPref("browser.sessionstore.restore_on_demand") && + Services.prefs.getBoolPref("browser.sessionstore.restore_tabs_lazily"); + + aState.windows.forEach(function (winState) { + winState.tabs.forEach(function (tabState) { + if (!restoreTabsLazily && (restoreHiddenTabs || !tabState.hidden)) { + expectedTabsRestored++; + } + }); + }); + + // If there are only hidden tabs and restoreHiddenTabs = false, we still + // expect one of them to be restored because it gets shown automatically. + // Otherwise if lazy tab restore there will only be one tab restored per window. + if (!expectedTabsRestored) { + expectedTabsRestored = 1; + } else if (restoreTabsLazily) { + expectedTabsRestored = aState.windows.length; + } + + function onSSTabRestored(aEvent) { + if (++tabsRestored == expectedTabsRestored) { + // Remove the event listener from each window + windows.forEach(function (win) { + win.gBrowser.tabContainer.removeEventListener( + "SSTabRestored", + onSSTabRestored, + true + ); + }); + listening = false; + info("running " + aSetStateCallback.name); + executeSoon(aSetStateCallback); + } + } + + // Used to add our listener to further windows so we can catch SSTabRestored + // coming from them when creating a multi-window state. + function windowObserver(aSubject, aTopic, aData) { + if (aTopic == "domwindowopened") { + let newWindow = aSubject; + newWindow.addEventListener( + "load", + function () { + if (++windowsOpen == expectedWindows) { + Services.ww.unregisterNotification(windowObserver); + windowObserving = false; + } + + // Track this window so we can remove the progress listener later + windows.push(newWindow); + // Add the progress listener + newWindow.gBrowser.tabContainer.addEventListener( + "SSTabRestored", + onSSTabRestored, + true + ); + }, + { once: true } + ); + } + } + + // We only want to register the notification if we expect more than 1 window + if (expectedWindows > 1) { + registerCleanupFunction(function () { + if (windowObserving) { + Services.ww.unregisterNotification(windowObserver); + } + }); + windowObserving = true; + Services.ww.registerNotification(windowObserver); + } + + registerCleanupFunction(function () { + if (listening) { + windows.forEach(function (win) { + win.gBrowser.tabContainer.removeEventListener( + "SSTabRestored", + onSSTabRestored, + true + ); + }); + } + }); + // Add the event listener for this window as well. + listening = true; + gBrowser.tabContainer.addEventListener( + "SSTabRestored", + onSSTabRestored, + true + ); + + // Ensure setBrowserState() doesn't remove the initial tab. + gBrowser.selectedTab = gBrowser.tabs[0]; + + // Finally, call setBrowserState + ss.setBrowserState(JSON.stringify(aState)); +} + +function promiseBrowserState(aState) { + return new Promise(resolve => waitForBrowserState(aState, resolve)); +} + +function promiseTabState(tab, state) { + if (typeof state != "string") { + state = JSON.stringify(state); + } + + let promise = promiseTabRestored(tab); + ss.setTabState(tab, state); + return promise; +} + +function promiseWindowRestoring(win) { + return new Promise(resolve => + win.addEventListener("SSWindowRestoring", resolve, { once: true }) + ); +} + +function promiseWindowRestored(win) { + return new Promise(resolve => + win.addEventListener("SSWindowRestored", resolve, { once: true }) + ); +} + +async function setBrowserState(state, win = window) { + ss.setBrowserState(typeof state != "string" ? JSON.stringify(state) : state); + await promiseWindowRestored(win); +} + +async function setWindowState(win, state, overwrite = false) { + ss.setWindowState( + win, + typeof state != "string" ? JSON.stringify(state) : state, + overwrite + ); + await promiseWindowRestored(win); +} + +function waitForTopic(aTopic, aTimeout, aCallback) { + let observing = false; + function removeObserver() { + if (!observing) { + return; + } + Services.obs.removeObserver(observer, aTopic); + observing = false; + } + + let timeout = setTimeout(function () { + removeObserver(); + aCallback(false); + }, aTimeout); + + function observer(subject, topic, data) { + removeObserver(); + timeout = clearTimeout(timeout); + executeSoon(() => aCallback(true)); + } + + registerCleanupFunction(function () { + removeObserver(); + if (timeout) { + clearTimeout(timeout); + } + }); + + observing = true; + Services.obs.addObserver(observer, aTopic); +} + +/** + * Wait until session restore has finished collecting its data and is + * has written that data ("sessionstore-state-write-complete"). + * + * @param {function} aCallback If sessionstore-state-write-complete is sent + * within buffering interval + 100 ms, the callback is passed |true|, + * otherwise, it is passed |false|. + */ +function waitForSaveState(aCallback) { + let timeout = + 100 + Services.prefs.getIntPref("browser.sessionstore.interval"); + return waitForTopic("sessionstore-state-write-complete", timeout, aCallback); +} +function promiseSaveState() { + return new Promise((resolve, reject) => { + waitForSaveState(isSuccessful => { + if (!isSuccessful) { + reject(new Error("Save state timeout")); + } else { + resolve(); + } + }); + }); +} +function forceSaveState() { + return SessionSaver.run(); +} + +function promiseRecoveryFileContents() { + let promise = forceSaveState(); + return promise.then(function () { + return IOUtils.readUTF8(SessionFile.Paths.recovery, { + decompress: true, + }); + }); +} + +var promiseForEachSessionRestoreFile = async function (cb) { + for (let key of SessionFile.Paths.loadOrder) { + let data = ""; + try { + data = await IOUtils.readUTF8(SessionFile.Paths[key], { + decompress: true, + }); + } catch (ex) { + // Ignore missing files + if (!(DOMException.isInstance(ex) && ex.name == "NotFoundError")) { + throw ex; + } + } + cb(data, key); + } +}; + +function promiseBrowserLoaded( + aBrowser, + ignoreSubFrames = true, + wantLoad = null +) { + return BrowserTestUtils.browserLoaded(aBrowser, !ignoreSubFrames, wantLoad); +} + +function whenWindowLoaded(aWindow, aCallback) { + aWindow.addEventListener( + "load", + function () { + executeSoon(function executeWhenWindowLoaded() { + aCallback(aWindow); + }); + }, + { once: true } + ); +} +function promiseWindowLoaded(aWindow) { + return new Promise(resolve => whenWindowLoaded(aWindow, resolve)); +} + +var gUniqueCounter = 0; +function r() { + return Date.now() + "-" + ++gUniqueCounter; +} + +function* BrowserWindowIterator() { + for (let currentWindow of Services.wm.getEnumerator("navigator:browser")) { + if (!currentWindow.closed) { + yield currentWindow; + } + } +} + +var gWebProgressListener = { + _callback: null, + + setCallback(aCallback) { + if (!this._callback) { + window.gBrowser.addTabsProgressListener(this); + } + this._callback = aCallback; + }, + + unsetCallback() { + if (this._callback) { + this._callback = null; + window.gBrowser.removeTabsProgressListener(this); + } + }, + + onStateChange(aBrowser, aWebProgress, aRequest, aStateFlags, aStatus) { + if ( + aStateFlags & Ci.nsIWebProgressListener.STATE_STOP && + aStateFlags & Ci.nsIWebProgressListener.STATE_IS_NETWORK && + aStateFlags & Ci.nsIWebProgressListener.STATE_IS_WINDOW + ) { + this._callback(aBrowser); + } + }, +}; + +registerCleanupFunction(function () { + gWebProgressListener.unsetCallback(); +}); + +var gProgressListener = { + _callback: null, + + setCallback(callback) { + Services.obs.addObserver(this, "sessionstore-debug-tab-restored"); + this._callback = callback; + }, + + unsetCallback() { + if (this._callback) { + this._callback = null; + Services.obs.removeObserver(this, "sessionstore-debug-tab-restored"); + } + }, + + observe(browser, topic, data) { + gProgressListener.onRestored(browser); + }, + + onRestored(browser) { + if (ss.getInternalObjectState(browser) == TAB_STATE_RESTORING) { + let args = [browser].concat(gProgressListener._countTabs()); + gProgressListener._callback.apply(gProgressListener, args); + } + }, + + _countTabs() { + let needsRestore = 0, + isRestoring = 0, + wasRestored = 0; + + for (let win of BrowserWindowIterator()) { + for (let i = 0; i < win.gBrowser.tabs.length; i++) { + let browser = win.gBrowser.tabs[i].linkedBrowser; + let state = ss.getInternalObjectState(browser); + if (browser.isConnected && !state) { + wasRestored++; + } else if (state == TAB_STATE_RESTORING) { + isRestoring++; + } else if (state == TAB_STATE_NEEDS_RESTORE || !browser.isConnected) { + needsRestore++; + } + } + } + return [needsRestore, isRestoring, wasRestored]; + }, +}; + +registerCleanupFunction(function () { + gProgressListener.unsetCallback(); +}); + +// Close all but our primary window. +function promiseAllButPrimaryWindowClosed() { + let windows = []; + for (let win of BrowserWindowIterator()) { + if (win != window) { + windows.push(win); + } + } + + return Promise.all(windows.map(BrowserTestUtils.closeWindow)); +} + +// Forget all closed windows. +function forgetClosedWindows() { + while (ss.getClosedWindowCount() > 0) { + ss.forgetClosedWindow(0); + } +} + +// Forget all closed tabs for a window +function forgetClosedTabs(win) { + while (ss.getClosedTabCountForWindow(win) > 0) { + ss.forgetClosedTab(win, 0); + } +} + +/** + * When opening a new window it is not sufficient to wait for its load event. + * We need to use whenDelayedStartupFinshed() here as the browser window's + * delayedStartup() routine is executed one tick after the window's load event + * has been dispatched. browser-delayed-startup-finished might be deferred even + * further if parts of the window's initialization process take more time than + * expected (e.g. reading a big session state from disk). + */ +function whenNewWindowLoaded(aOptions, aCallback) { + let features = ""; + let url = "about:blank"; + + if ((aOptions && aOptions.private) || false) { + features = ",private"; + url = "about:privatebrowsing"; + } + + let win = openDialog( + AppConstants.BROWSER_CHROME_URL, + "", + "chrome,all,dialog=no" + features, + url + ); + let delayedStartup = promiseDelayedStartupFinished(win); + + let browserLoaded = new Promise(resolve => { + if (url == "about:blank") { + resolve(); + return; + } + + win.addEventListener( + "load", + function () { + let browser = win.gBrowser.selectedBrowser; + promiseBrowserLoaded(browser).then(resolve); + }, + { once: true } + ); + }); + + Promise.all([delayedStartup, browserLoaded]).then(() => aCallback(win)); +} +function promiseNewWindowLoaded(aOptions) { + return new Promise(resolve => whenNewWindowLoaded(aOptions, resolve)); +} + +/** + * This waits for the browser-delayed-startup-finished notification of a given + * window. It indicates that the windows has loaded completely and is ready to + * be used for testing. + */ +function whenDelayedStartupFinished(aWindow, aCallback) { + Services.obs.addObserver(function observer(aSubject, aTopic) { + if (aWindow == aSubject) { + Services.obs.removeObserver(observer, aTopic); + executeSoon(aCallback); + } + }, "browser-delayed-startup-finished"); +} +function promiseDelayedStartupFinished(aWindow) { + return new Promise(resolve => whenDelayedStartupFinished(aWindow, resolve)); +} + +function promiseTabRestored(tab) { + return BrowserTestUtils.waitForEvent(tab, "SSTabRestored"); +} + +function promiseTabRestoring(tab) { + return BrowserTestUtils.waitForEvent(tab, "SSTabRestoring"); +} + +// Removes the given tab immediately and returns a promise that resolves when +// all pending status updates (messages) of the closing tab have been received. +function promiseRemoveTabAndSessionState(tab) { + let sessionUpdatePromise = BrowserTestUtils.waitForSessionStoreUpdate(tab); + BrowserTestUtils.removeTab(tab); + return sessionUpdatePromise; +} + +// Write DOMSessionStorage data to the given browser. +function modifySessionStorage(browser, storageData, storageOptions = {}) { + let browsingContext = browser.browsingContext; + if (storageOptions && "frameIndex" in storageOptions) { + browsingContext = browsingContext.children[storageOptions.frameIndex]; + } + + return SpecialPowers.spawn( + browsingContext, + [[storageData, storageOptions]], + async function ([data, options]) { + let frame = content; + let keys = new Set(Object.keys(data)); + let isClearing = !keys.size; + let storage = frame.sessionStorage; + + return new Promise(resolve => { + docShell.chromeEventHandler.addEventListener( + "MozSessionStorageChanged", + function onStorageChanged(event) { + if (event.storageArea == storage) { + keys.delete(event.key); + } + + if (keys.size == 0) { + docShell.chromeEventHandler.removeEventListener( + "MozSessionStorageChanged", + onStorageChanged, + true + ); + resolve(); + } + }, + true + ); + + if (isClearing) { + storage.clear(); + } else { + for (let key of keys) { + frame.sessionStorage[key] = data[key]; + } + } + }); + } + ); +} + +function pushPrefs(...aPrefs) { + return SpecialPowers.pushPrefEnv({ set: aPrefs }); +} + +function popPrefs() { + return SpecialPowers.popPrefEnv(); +} + +function setScrollPosition(bc, x, y) { + return SpecialPowers.spawn(bc, [x, y], (childX, childY) => { + return new Promise(resolve => { + content.addEventListener( + "mozvisualscroll", + function onScroll(event) { + if (content.document.ownerGlobal.visualViewport == event.target) { + content.removeEventListener("mozvisualscroll", onScroll, { + mozSystemGroup: true, + }); + resolve(); + } + }, + { mozSystemGroup: true } + ); + content.scrollTo(childX, childY); + }); + }); +} + +async function checkScroll(tab, expected, msg) { + let browser = tab.linkedBrowser; + await TabStateFlusher.flush(browser); + + let scroll = JSON.parse(ss.getTabState(tab)).scroll || null; + is(JSON.stringify(scroll), JSON.stringify(expected), msg); +} + +function whenDomWindowClosedHandled(aCallback) { + Services.obs.addObserver(function observer(aSubject, aTopic) { + Services.obs.removeObserver(observer, aTopic); + aCallback(); + }, "sessionstore-debug-domwindowclosed-handled"); +} + +function getPropertyOfFormField(browserContext, selector, propName) { + return SpecialPowers.spawn( + browserContext, + [selector, propName], + (selectorChild, propNameChild) => { + return content.document.querySelector(selectorChild)[propNameChild]; + } + ); +} + +function setPropertyOfFormField(browserContext, selector, propName, newValue) { + return SpecialPowers.spawn( + browserContext, + [selector, propName, newValue], + (selectorChild, propNameChild, newValueChild) => { + let node = content.document.querySelector(selectorChild); + node[propNameChild] = newValueChild; + + let event = node.ownerDocument.createEvent("UIEvents"); + event.initUIEvent("input", true, true, node.ownerGlobal, 0); + node.dispatchEvent(event); + } + ); +} + +function promiseOnHistoryReplaceEntry(browser) { + if (SpecialPowers.Services.appinfo.sessionHistoryInParent) { + return new Promise(resolve => { + let sessionHistory = browser.browsingContext?.sessionHistory; + if (sessionHistory) { + var historyListener = { + OnHistoryNewEntry() {}, + OnHistoryGotoIndex() {}, + OnHistoryPurge() {}, + OnHistoryReload() { + return true; + }, + + OnHistoryReplaceEntry() { + resolve(); + }, + + QueryInterface: ChromeUtils.generateQI([ + "nsISHistoryListener", + "nsISupportsWeakReference", + ]), + }; + + sessionHistory.addSHistoryListener(historyListener); + } + }); + } + + return SpecialPowers.spawn(browser, [], () => { + return new Promise(resolve => { + var historyListener = { + OnHistoryNewEntry() {}, + OnHistoryGotoIndex() {}, + OnHistoryPurge() {}, + OnHistoryReload() { + return true; + }, + + OnHistoryReplaceEntry() { + resolve(); + }, + + QueryInterface: ChromeUtils.generateQI([ + "nsISHistoryListener", + "nsISupportsWeakReference", + ]), + }; + + var { sessionHistory } = this.docShell.QueryInterface( + Ci.nsIWebNavigation + ); + if (sessionHistory) { + sessionHistory.legacySHistory.addSHistoryListener(historyListener); + } + }); + }); +} + +function loadTestSubscript(filePath) { + Services.scriptloader.loadSubScript(new URL(filePath, gTestPath).href, this); +} + +function addCoopTask(aFile, aTest, aUrlRoot) { + async function taskToBeAdded() { + info(`File ${aFile} has COOP headers enabled`); + let filePath = `browser/browser/components/sessionstore/test/${aFile}`; + let url = aUrlRoot + `coopHeaderCommon.sjs?fileRoot=${filePath}`; + await aTest(url); + } + Object.defineProperty(taskToBeAdded, "name", { value: aTest.name }); + add_task(taskToBeAdded); +} + +function addNonCoopTask(aFile, aTest, aUrlRoot) { + async function taskToBeAdded() { + await aTest(aUrlRoot + aFile); + } + Object.defineProperty(taskToBeAdded, "name", { value: aTest.name }); + add_task(taskToBeAdded); +} + +async function openAndCloseTab(window, url) { + let tab = BrowserTestUtils.addTab(window.gBrowser, url); + await promiseBrowserLoaded(tab.linkedBrowser, true, url); + await TabStateFlusher.flush(tab.linkedBrowser); + await promiseRemoveTabAndSessionState(tab); +} + +/** + * This is regrettable, but when `promiseBrowserState` resolves, we're still + * midway through loading the tabs. To avoid race conditions in URLs for tabs + * being available, wait for all the loads to finish: + */ +function promiseSessionStoreLoads(numberOfLoads) { + let loadsSeen = 0; + return new Promise(resolve => { + Services.obs.addObserver(function obs(browser) { + loadsSeen++; + if (loadsSeen == numberOfLoads) { + resolve(); + } + // The typeof check is here to avoid one test messing with everything else by + // keeping the observer indefinitely. + if (typeof info == "undefined" || loadsSeen >= numberOfLoads) { + Services.obs.removeObserver(obs, "sessionstore-debug-tab-restored"); + } + info("Saw load for " + browser.currentURI.spec); + }, "sessionstore-debug-tab-restored"); + }); +} diff --git a/browser/components/sessionstore/test/marionette/manifest.ini b/browser/components/sessionstore/test/marionette/manifest.ini new file mode 100644 index 0000000000..4664c5912a --- /dev/null +++ b/browser/components/sessionstore/test/marionette/manifest.ini @@ -0,0 +1,14 @@ +[DEFAULT] +tags = local + +[test_restore_loading_tab.py] +[test_restore_manually_with_pinned_tabs.py] +[test_restore_windows_after_restart_and_quit.py] +[test_restore_windows_after_windows_shutdown.py] +skip-if = + os != "win" + win10_2004 # Bug 1727691 + win11_2009 # Bug 1727691 +[test_restore_windows_after_close_last_tabs.py] +skip-if = + os == "mac" diff --git a/browser/components/sessionstore/test/marionette/session_store_test_case.py b/browser/components/sessionstore/test/marionette/session_store_test_case.py new file mode 100644 index 0000000000..3bcbcd3f56 --- /dev/null +++ b/browser/components/sessionstore/test/marionette/session_store_test_case.py @@ -0,0 +1,432 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.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 urllib.parse import quote + +from marionette_driver import Wait, errors +from marionette_driver.keys import Keys +from marionette_harness import MarionetteTestCase, WindowManagerMixin + + +def inline(doc): + return "data:text/html;charset=utf-8,{}".format(quote(doc)) + + +# Each list element represents a window of tabs loaded at +# some testing URL +DEFAULT_WINDOWS = set( + [ + # Window 1. Note the comma after the inline call - + # this is Python's way of declaring a 1 item tuple. + (inline("""Lorem"""),), + # Window 2 + ( + inline("""ipsum"""), + inline("""dolor"""), + ), + # Window 3 + ( + inline("""sit"""), + inline("""amet"""), + ), + ] +) + + +class SessionStoreTestCase(WindowManagerMixin, MarionetteTestCase): + def setUp( + self, + startup_page=1, + include_private=True, + restore_on_demand=False, + no_auto_updates=True, + win_register_restart=False, + test_windows=DEFAULT_WINDOWS, + ): + super(SessionStoreTestCase, self).setUp() + self.marionette.set_context("chrome") + + platform = self.marionette.session_capabilities["platformName"] + self.accelKey = Keys.META if platform == "mac" else Keys.CONTROL + + self.test_windows = test_windows + + self.private_windows = set( + [ + ( + inline("""consectetur"""), + inline("""ipsum"""), + ), + ( + inline("""adipiscing"""), + inline("""consectetur"""), + ), + ] + ) + + self.marionette.enforce_gecko_prefs( + { + # Set browser restore previous session pref, + # depending on what the test requires. + "browser.startup.page": startup_page, + # Make the content load right away instead of waiting for + # the user to click on the background tabs + "browser.sessionstore.restore_on_demand": restore_on_demand, + # Avoid race conditions by having the content process never + # send us session updates unless the parent has explicitly asked + # for them via the TabStateFlusher. + "browser.sessionstore.debug.no_auto_updates": no_auto_updates, + # Whether to enable the register application restart mechanism. + "toolkit.winRegisterApplicationRestart": win_register_restart, + } + ) + + self.all_windows = self.test_windows.copy() + self.open_windows(self.test_windows) + + if include_private: + self.all_windows.update(self.private_windows) + self.open_windows(self.private_windows, is_private=True) + + def tearDown(self): + try: + # Create a fresh profile for subsequent tests. + self.marionette.restart(in_app=False, clean=True) + finally: + super(SessionStoreTestCase, self).tearDown() + + def open_windows(self, window_sets, is_private=False): + """Open a set of windows with tabs pointing at some URLs. + + @param window_sets (list) + A set of URL tuples. Each tuple within window_sets + represents a window, and each URL in the URL + tuples represents what will be loaded in a tab. + + Note that if is_private is False, then the first + URL tuple will be opened in the current window, and + subequent tuples will be opened in new windows. + + Example: + + set( + (self.marionette.absolute_url('layout/mozilla_1.html'), + self.marionette.absolute_url('layout/mozilla_2.html')), + + (self.marionette.absolute_url('layout/mozilla_3.html'), + self.marionette.absolute_url('layout/mozilla_4.html')), + ) + + This would take the currently open window, and load + mozilla_1.html and mozilla_2.html in new tabs. It would + then open a new, second window, and load tabs at + mozilla_3.html and mozilla_4.html. + @param is_private (boolean, optional) + Whether or not any new windows should be a private browsing + windows. + """ + if is_private: + win = self.open_window(private=True) + self.marionette.switch_to_window(win) + else: + win = self.marionette.current_chrome_window_handle + + for index, urls in enumerate(window_sets): + if index > 0: + win = self.open_window(private=is_private) + self.marionette.switch_to_window(win) + self.open_tabs(win, urls) + + def open_tabs(self, win, urls): + """Open a set of URLs inside a window in new tabs. + + @param win (browser window) + The browser window to load the tabs in. + @param urls (tuple) + A tuple of URLs to load in this window. The + first URL will be loaded in the currently selected + browser tab. Subsequent URLs will be loaded in + new tabs. + """ + # If there are any remaining URLs for this window, + # open some new tabs and navigate to them. + with self.marionette.using_context("content"): + if isinstance(urls, str): + self.marionette.navigate(urls) + else: + for index, url in enumerate(urls): + if index > 0: + tab = self.open_tab() + self.marionette.switch_to_window(tab) + self.marionette.navigate(url) + + def wait_for_windows(self, expected_windows, message, timeout=5): + current_windows = None + + def check(_): + nonlocal current_windows + current_windows = self.convert_open_windows_to_set() + return current_windows == expected_windows + + try: + wait = Wait(self.marionette, timeout=timeout, interval=0.1) + wait.until(check, message=message) + except errors.TimeoutException as e: + # Update the message to include the most recent list of windows + message = ( + f"{e.message}. Expected {expected_windows}, got {current_windows}." + ) + raise errors.TimeoutException(message) + + def get_urls_for_window(self, win): + orig_handle = self.marionette.current_chrome_window_handle + + try: + with self.marionette.using_context("chrome"): + self.marionette.switch_to_window(win) + return self.marionette.execute_script( + """ + return gBrowser.tabs.map(tab => { + return tab.linkedBrowser.currentURI.spec; + }); + """ + ) + finally: + self.marionette.switch_to_window(orig_handle) + + def convert_open_windows_to_set(self): + # There's no guarantee that Marionette will return us an + # iterator for the opened windows that will match the + # order within our window list. Instead, we'll convert + # the list of URLs within each open window to a set of + # tuples that will allow us to do a direct comparison + # while allowing the windows to be in any order. + opened_windows = set() + for win in self.marionette.chrome_window_handles: + urls = tuple(self.get_urls_for_window(win)) + opened_windows.add(urls) + + return opened_windows + + def _close_tab_shortcut(self): + self.marionette.actions.sequence("key", "keyboard_id").key_down( + self.accelKey + ).key_down("w").key_up("w").key_up(self.accelKey).perform() + + def close_all_tabs_and_restart(self): + self.close_all_tabs() + self.marionette.quit(callback=self._close_tab_shortcut) + self.marionette.start_session() + + def simulate_os_shutdown(self): + """Simulate an OS shutdown. + + :raises: Exception: if not supported on the current platform + :raises: WindowsError: if a Windows API call failed + """ + if self.marionette.session_capabilities["platformName"] != "windows": + raise Exception("Unsupported platform for simulate_os_shutdown") + + self._shutdown_with_windows_restart_manager(self.marionette.process_id) + + def _shutdown_with_windows_restart_manager(self, pid): + """Shut down a process using the Windows Restart Manager. + + When Windows shuts down, it uses a protocol including the + WM_QUERYENDSESSION and WM_ENDSESSION messages to give + applications a chance to shut down safely. The best way to + simulate this is via the Restart Manager, which allows a process + (such as an installer) to use the same mechanism to shut down + any other processes which are using registered resources. + + This function starts a Restart Manager session, registers the + process as a resource, and shuts down the process. + + :param pid: The process id (int) of the process to shutdown + + :raises: WindowsError: if a Windows API call fails + """ + import ctypes + from ctypes import POINTER, WINFUNCTYPE, Structure, WinError, pointer, windll + from ctypes.wintypes import BOOL, DWORD, HANDLE, LPCWSTR, UINT, ULONG, WCHAR + + # set up Windows SDK types + OpenProcess = windll.kernel32.OpenProcess + OpenProcess.restype = HANDLE + OpenProcess.argtypes = [ + DWORD, # dwDesiredAccess + BOOL, # bInheritHandle + DWORD, + ] # dwProcessId + PROCESS_QUERY_INFORMATION = 0x0400 + + class FILETIME(Structure): + _fields_ = [("dwLowDateTime", DWORD), ("dwHighDateTime", DWORD)] + + LPFILETIME = POINTER(FILETIME) + + GetProcessTimes = windll.kernel32.GetProcessTimes + GetProcessTimes.restype = BOOL + GetProcessTimes.argtypes = [ + HANDLE, # hProcess + LPFILETIME, # lpCreationTime + LPFILETIME, # lpExitTime + LPFILETIME, # lpKernelTime + LPFILETIME, + ] # lpUserTime + + ERROR_SUCCESS = 0 + + class RM_UNIQUE_PROCESS(Structure): + _fields_ = [("dwProcessId", DWORD), ("ProcessStartTime", FILETIME)] + + RmStartSession = windll.rstrtmgr.RmStartSession + RmStartSession.restype = DWORD + RmStartSession.argtypes = [ + POINTER(DWORD), # pSessionHandle + DWORD, # dwSessionFlags + POINTER(WCHAR), + ] # strSessionKey + + class GUID(ctypes.Structure): + _fields_ = [ + ("Data1", ctypes.c_ulong), + ("Data2", ctypes.c_ushort), + ("Data3", ctypes.c_ushort), + ("Data4", ctypes.c_ubyte * 8), + ] + + CCH_RM_SESSION_KEY = ctypes.sizeof(GUID) * 2 + + RmRegisterResources = windll.rstrtmgr.RmRegisterResources + RmRegisterResources.restype = DWORD + RmRegisterResources.argtypes = [ + DWORD, # dwSessionHandle + UINT, # nFiles + POINTER(LPCWSTR), # rgsFilenames + UINT, # nApplications + POINTER(RM_UNIQUE_PROCESS), # rgApplications + UINT, # nServices + POINTER(LPCWSTR), + ] # rgsServiceNames + + RM_WRITE_STATUS_CALLBACK = WINFUNCTYPE(None, UINT) + RmShutdown = windll.rstrtmgr.RmShutdown + RmShutdown.restype = DWORD + RmShutdown.argtypes = [ + DWORD, # dwSessionHandle + ULONG, # lActionFlags + RM_WRITE_STATUS_CALLBACK, + ] # fnStatus + + RmEndSession = windll.rstrtmgr.RmEndSession + RmEndSession.restype = DWORD + RmEndSession.argtypes = [DWORD] # dwSessionHandle + + # Get the info needed to uniquely identify the process + hProc = OpenProcess(PROCESS_QUERY_INFORMATION, False, pid) + if not hProc: + raise WinError() + + creationTime = FILETIME() + exitTime = FILETIME() + kernelTime = FILETIME() + userTime = FILETIME() + if not GetProcessTimes( + hProc, + pointer(creationTime), + pointer(exitTime), + pointer(kernelTime), + pointer(userTime), + ): + raise WinError() + + # Start the Restart Manager Session + dwSessionHandle = DWORD() + sessionKeyType = WCHAR * (CCH_RM_SESSION_KEY + 1) + sessionKey = sessionKeyType() + if RmStartSession(pointer(dwSessionHandle), 0, sessionKey) != ERROR_SUCCESS: + raise WinError() + + try: + UProcs_count = 1 + UProcsArrayType = RM_UNIQUE_PROCESS * UProcs_count + UProcs = UProcsArrayType(RM_UNIQUE_PROCESS(pid, creationTime)) + + # Register the process as a resource + if ( + RmRegisterResources( + dwSessionHandle, 0, None, UProcs_count, UProcs, 0, None + ) + != ERROR_SUCCESS + ): + raise WinError() + + # Shut down all processes using registered resources + if ( + RmShutdown( + dwSessionHandle, 0, ctypes.cast(None, RM_WRITE_STATUS_CALLBACK) + ) + != ERROR_SUCCESS + ): + raise WinError() + + finally: + RmEndSession(dwSessionHandle) + + def windows_shutdown_with_variety(self, restart_by_os, expect_restore): + """Test restoring windows after Windows shutdown. + + Opens a set of windows, both standard and private, with + some number of tabs in them. Once the tabs have loaded, shuts down + the browser with the Windows Restart Manager and restarts the browser. + + This specifically exercises the Windows synchronous shutdown mechanism, + which terminates the process in response to the Restart Manager's + WM_ENDSESSION message. + + If restart_by_os is True, the -os-restarted arg is passed when restarting, + simulating being automatically restarted by the Restart Manager. + + If expect_restore is True, this ensures that the standard tabs have been + restored, and that the private ones have not. Otherwise it ensures that + no tabs and windows have been restored. + """ + current_windows_set = self.convert_open_windows_to_set() + self.assertEqual( + current_windows_set, + self.all_windows, + msg="Not all requested windows have been opened. Expected {}, got {}.".format( + self.all_windows, current_windows_set + ), + ) + + self.marionette.quit(callback=lambda: self.simulate_os_shutdown()) + + saved_args = self.marionette.instance.app_args + try: + if restart_by_os: + self.marionette.instance.app_args = ["-os-restarted"] + + self.marionette.start_session() + self.marionette.set_context("chrome") + finally: + self.marionette.instance.app_args = saved_args + + if expect_restore: + self.wait_for_windows( + self.test_windows, + "Non private browsing windows should have been restored", + ) + else: + self.assertEqual( + len(self.marionette.chrome_window_handles), + 1, + msg="Windows from last session shouldn`t have been restored.", + ) + self.assertEqual( + len(self.marionette.window_handles), + 1, + msg="Tabs from last session shouldn`t have been restored.", + ) diff --git a/browser/components/sessionstore/test/marionette/test_restore_loading_tab.py b/browser/components/sessionstore/test/marionette/test_restore_loading_tab.py new file mode 100644 index 0000000000..f053081b02 --- /dev/null +++ b/browser/components/sessionstore/test/marionette/test_restore_loading_tab.py @@ -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/. + +from urllib.parse import quote + +from marionette_harness import MarionetteTestCase, WindowManagerMixin + + +def inline(doc): + return "data:text/html;charset=utf-8,{}".format(quote(doc)) + + +class TestRestoreLoadingPage(WindowManagerMixin, MarionetteTestCase): + def setUp(self): + super(TestRestoreLoadingPage, self).setUp() + self.delayed_page = self.marionette.absolute_url("slow") + + def do_test(self, html, is_restoring_expected): + self.marionette.navigate(inline(html.format(self.delayed_page))) + link = self.marionette.find_element("id", "link") + link.click() + + self.marionette.restart(in_app=True) + + with self.marionette.using_context("chrome"): + urls = self.marionette.execute_script( + "return gBrowser.tabs.map(t => t.linkedBrowser.currentURI.spec);" + ) + + if is_restoring_expected: + self.assertEqual( + len(urls), + 2, + msg="The tab opened should be restored", + ) + self.assertEqual( + urls[1], + self.delayed_page, + msg="The tab restored is correct", + ) + else: + self.assertEqual( + len(urls), + 1, + msg="The tab opened should not be restored", + ) + + self.close_all_tabs() + + def test_target_blank(self): + self.do_test("click", True) + + def test_target_other(self): + self.do_test("click", False) + + def test_by_script(self): + self.do_test( + """ + click + + """, + False, + ) diff --git a/browser/components/sessionstore/test/marionette/test_restore_manually_with_pinned_tabs.py b/browser/components/sessionstore/test/marionette/test_restore_manually_with_pinned_tabs.py new file mode 100644 index 0000000000..fa00c25a4c --- /dev/null +++ b/browser/components/sessionstore/test/marionette/test_restore_manually_with_pinned_tabs.py @@ -0,0 +1,108 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import os +import sys +from urllib.parse import quote + +# add this directory to the path +sys.path.append(os.path.dirname(__file__)) + +from marionette_driver import Wait, errors +from session_store_test_case import SessionStoreTestCase + + +def inline(doc): + return "data:text/html;charset=utf-8,{}".format(quote(doc)) + + +class TestSessionRestoreWithPinnedTabs(SessionStoreTestCase): + def setUp(self): + super(TestSessionRestoreWithPinnedTabs, self).setUp( + startup_page=1, + include_private=False, + restore_on_demand=True, + test_windows=set( + [ + # Window 1 + ( + inline("""ipsum"""), + inline("""dolor"""), + inline("""amet"""), + ), + ] + ), + ) + + def test_no_restore_with_quit(self): + self.wait_for_windows( + self.all_windows, "Not all requested windows have been opened" + ) + # add pinned tab in first window. + self.marionette.execute_async_script( + """ + let resolve = arguments[0]; + gBrowser.pinTab(gBrowser.tabs[0]); + let { TabStateFlusher } = ChromeUtils.importESModule("resource:///modules/sessionstore/TabStateFlusher.sys.mjs"); + TabStateFlusher.flush(gBrowser.tabs[0]).then(resolve); + """ + ) + + self.marionette.quit() + self.marionette.start_session() + self.marionette.set_context("chrome") + + self.assertEqual( + self.marionette.execute_script("return gBrowser.tabs.length"), + 2, + msg="Should have 2 tabs.", + ) + + self.assertEqual( + self.marionette.execute_script( + "return gBrowser.tabs.filter(t => t.pinned).length" + ), + 1, + msg="Pinned tab should have been restored.", + ) + + self.marionette.execute_script( + """ + SessionStore.restoreLastSession(); + """ + ) + self.wait_for_tabcount(3, "Waiting for 3 tabs") + + self.assertEqual( + self.marionette.execute_script("return gBrowser.tabs.length"), + 3, + msg="Should have 2 tabs.", + ) + self.assertEqual( + self.marionette.execute_script( + "return gBrowser.tabs.filter(t => t.pinned).length" + ), + 1, + msg="Should still have 1 pinned tab", + ) + + def wait_for_tabcount(self, expected_tabcount, message, timeout=5): + current_tabcount = None + + def check(_): + nonlocal current_tabcount + current_tabcount = self.marionette.execute_script( + "return gBrowser.tabs.length;" + ) + return current_tabcount == expected_tabcount + + try: + wait = Wait(self.marionette, timeout=timeout, interval=0.1) + wait.until(check, message=message) + except errors.TimeoutException as e: + # Update the message to include the most recent list of windows + message = ( + f"{e.message}. Expected {expected_tabcount}, got {current_tabcount}." + ) + raise errors.TimeoutException(message) diff --git a/browser/components/sessionstore/test/marionette/test_restore_windows_after_close_last_tabs.py b/browser/components/sessionstore/test/marionette/test_restore_windows_after_close_last_tabs.py new file mode 100644 index 0000000000..2022d8fb87 --- /dev/null +++ b/browser/components/sessionstore/test/marionette/test_restore_windows_after_close_last_tabs.py @@ -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/. + +# add this directory to the path +import os +import sys + +sys.path.append(os.path.dirname(__file__)) + +from session_store_test_case import SessionStoreTestCase + + +class TestSessionStoreEnabledAllWindows(SessionStoreTestCase): + def setUp(self, include_private=True): + """Setup for the test, enabling session restore. + + :param include_private: Whether to open private windows. + """ + super(TestSessionStoreEnabledAllWindows, self).setUp( + include_private=include_private, startup_page=3 + ) + + def test_with_variety(self): + """Test opening and restoring both standard and private windows. + + Opens a set of windows, both standard and private, with + some number of tabs in them. Once the tabs have loaded, restarts + the browser, and then ensures that the standard tabs have been + restored, and that the private ones have not. + """ + self.wait_for_windows( + self.all_windows, "Not all requested windows have been opened" + ) + + self.marionette.quit() + self.marionette.start_session() + + self.wait_for_windows( + self.test_windows, "Non private browsing windows should have been restored" + ) + + def test_close_tabs(self): + self.wait_for_windows( + self.all_windows, "Not all requested windows have been opened" + ) + + self.close_all_tabs_and_restart() + + self.assertEqual( + len(self.marionette.chrome_window_handles), + 1, + msg="Windows from last session shouldn`t have been restored.", + ) + self.assertEqual( + len(self.marionette.window_handles), + 1, + msg="Tabs from last session shouldn`t have been restored.", + ) diff --git a/browser/components/sessionstore/test/marionette/test_restore_windows_after_restart_and_quit.py b/browser/components/sessionstore/test/marionette/test_restore_windows_after_restart_and_quit.py new file mode 100644 index 0000000000..be17f08472 --- /dev/null +++ b/browser/components/sessionstore/test/marionette/test_restore_windows_after_restart_and_quit.py @@ -0,0 +1,82 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +# add this directory to the path +import os +import sys + +sys.path.append(os.path.dirname(__file__)) + +from session_store_test_case import SessionStoreTestCase + + +class TestSessionStoreEnabledAllWindows(SessionStoreTestCase): + def setUp(self, include_private=True): + """Setup for the test, enabling session restore. + + :param include_private: Whether to open private windows. + """ + super(TestSessionStoreEnabledAllWindows, self).setUp( + include_private=include_private, startup_page=3 + ) + + def test_with_variety(self): + """Test opening and restoring both standard and private windows. + + Opens a set of windows, both standard and private, with + some number of tabs in them. Once the tabs have loaded, restarts + the browser, and then ensures that the standard tabs have been + restored, and that the private ones have not. + """ + self.wait_for_windows( + self.all_windows, "Not all requested windows have been opened" + ) + + self.marionette.quit() + self.marionette.start_session() + self.marionette.set_context("chrome") + + self.wait_for_windows( + self.test_windows, "Non private browsing windows should have been restored" + ) + + +class TestSessionStoreEnabledNoPrivateWindows(TestSessionStoreEnabledAllWindows): + def setUp(self): + super(TestSessionStoreEnabledNoPrivateWindows, self).setUp( + include_private=False + ) + + +class TestSessionStoreDisabled(SessionStoreTestCase): + def test_no_restore_with_quit(self): + self.wait_for_windows( + self.all_windows, "Not all requested windows have been opened" + ) + + self.marionette.quit() + self.marionette.start_session() + self.marionette.set_context("chrome") + + self.assertEqual( + len(self.marionette.chrome_window_handles), + 1, + msg="Windows from last session shouldn`t have been restored.", + ) + self.assertEqual( + len(self.marionette.window_handles), + 1, + msg="Tabs from last session shouldn`t have been restored.", + ) + + def test_restore_with_restart(self): + self.wait_for_windows( + self.all_windows, "Not all requested windows have been opened" + ) + + self.marionette.restart(in_app=True) + + self.wait_for_windows( + self.test_windows, "Non private browsing windows should have been restored" + ) diff --git a/browser/components/sessionstore/test/marionette/test_restore_windows_after_windows_shutdown.py b/browser/components/sessionstore/test/marionette/test_restore_windows_after_windows_shutdown.py new file mode 100644 index 0000000000..21eec455bb --- /dev/null +++ b/browser/components/sessionstore/test/marionette/test_restore_windows_after_windows_shutdown.py @@ -0,0 +1,66 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +# add this directory to the path +import os +import sys + +sys.path.append(os.path.dirname(__file__)) + +from session_store_test_case import SessionStoreTestCase + +# We test the following combinations with simulated Windows shutdown: +# - Start page = restore session (expect restore in all cases) +# - RAR (toolkit.winRegisterApplicationRestart) disabled +# - RAR enabled, restarted manually +# +# - Start page = home +# - RAR disabled (no restore) +# - RAR enabled: +# - restarted by OS (restore) +# - restarted manually (no restore) + + +class TestWindowsShutdown(SessionStoreTestCase): + def setUp(self): + super(TestWindowsShutdown, self).setUp(startup_page=3, no_auto_updates=False) + + def test_with_variety(self): + """Test session restore selected by user.""" + self.windows_shutdown_with_variety(restart_by_os=False, expect_restore=True) + + +class TestWindowsShutdownRegisterRestart(SessionStoreTestCase): + def setUp(self): + super(TestWindowsShutdownRegisterRestart, self).setUp( + startup_page=3, no_auto_updates=False, win_register_restart=True + ) + + def test_manual_restart(self): + """Test that restore tabs works in case of register restart failure.""" + self.windows_shutdown_with_variety(restart_by_os=False, expect_restore=True) + + +class TestWindowsShutdownNormal(SessionStoreTestCase): + def setUp(self): + super(TestWindowsShutdownNormal, self).setUp(no_auto_updates=False) + + def test_with_variety(self): + """Test that windows are not restored on a normal restart.""" + self.windows_shutdown_with_variety(restart_by_os=False, expect_restore=False) + + +class TestWindowsShutdownForcedSessionRestore(SessionStoreTestCase): + def setUp(self): + super(TestWindowsShutdownForcedSessionRestore, self).setUp( + no_auto_updates=False, win_register_restart=True + ) + + def test_os_restart(self): + """Test that register application restart restores the session.""" + self.windows_shutdown_with_variety(restart_by_os=True, expect_restore=True) + + def test_manual_restart(self): + """Test that OS shutdown is ignored on manual start.""" + self.windows_shutdown_with_variety(restart_by_os=False, expect_restore=False) diff --git a/browser/components/sessionstore/test/restore_redirect_http.html b/browser/components/sessionstore/test/restore_redirect_http.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/browser/components/sessionstore/test/restore_redirect_http.html^headers^ b/browser/components/sessionstore/test/restore_redirect_http.html^headers^ new file mode 100644 index 0000000000..533bda36f3 --- /dev/null +++ b/browser/components/sessionstore/test/restore_redirect_http.html^headers^ @@ -0,0 +1,2 @@ +HTTP 302 Moved Temporarily +Location: restore_redirect_target.html diff --git a/browser/components/sessionstore/test/restore_redirect_js.html b/browser/components/sessionstore/test/restore_redirect_js.html new file mode 100644 index 0000000000..f0130847b6 --- /dev/null +++ b/browser/components/sessionstore/test/restore_redirect_js.html @@ -0,0 +1,10 @@ + + + + + + + diff --git a/browser/components/sessionstore/test/restore_redirect_target.html b/browser/components/sessionstore/test/restore_redirect_target.html new file mode 100644 index 0000000000..813af05508 --- /dev/null +++ b/browser/components/sessionstore/test/restore_redirect_target.html @@ -0,0 +1,8 @@ + + + + +Test page + +Test page + diff --git a/browser/components/sessionstore/test/unit/data/sessionCheckpoints_all.json b/browser/components/sessionstore/test/unit/data/sessionCheckpoints_all.json new file mode 100644 index 0000000000..e02c421c3b --- /dev/null +++ b/browser/components/sessionstore/test/unit/data/sessionCheckpoints_all.json @@ -0,0 +1,11 @@ +{ + "profile-after-change": true, + "final-ui-startup": true, + "sessionstore-windows-restored": true, + "quit-application-granted": true, + "quit-application": true, + "sessionstore-final-state-write-complete": true, + "profile-change-net-teardown": true, + "profile-change-teardown": true, + "profile-before-change": true +} diff --git a/browser/components/sessionstore/test/unit/data/sessionstore_invalid.js b/browser/components/sessionstore/test/unit/data/sessionstore_invalid.js new file mode 100644 index 0000000000..a8c3ff2ff9 --- /dev/null +++ b/browser/components/sessionstore/test/unit/data/sessionstore_invalid.js @@ -0,0 +1,3 @@ +{ + "windows": // invalid json +} diff --git a/browser/components/sessionstore/test/unit/data/sessionstore_valid.js b/browser/components/sessionstore/test/unit/data/sessionstore_valid.js new file mode 100644 index 0000000000..f9511f29f6 --- /dev/null +++ b/browser/components/sessionstore/test/unit/data/sessionstore_valid.js @@ -0,0 +1,3 @@ +{ + "windows": [] +} \ No newline at end of file diff --git a/browser/components/sessionstore/test/unit/head.js b/browser/components/sessionstore/test/unit/head.js new file mode 100644 index 0000000000..b342a886fb --- /dev/null +++ b/browser/components/sessionstore/test/unit/head.js @@ -0,0 +1,36 @@ +ChromeUtils.defineESModuleGetters(this, { + SessionStartup: "resource:///modules/sessionstore/SessionStartup.sys.mjs", +}); + +// Call a function once initialization of SessionStartup is complete +function afterSessionStartupInitialization(cb) { + info("Waiting for session startup initialization"); + let observer = function () { + try { + info("Session startup initialization observed"); + Services.obs.removeObserver(observer, "sessionstore-state-finalized"); + cb(); + } catch (ex) { + do_throw(ex); + } + }; + Services.obs.addObserver(observer, "sessionstore-state-finalized"); + + // We need the Crash Monitor initialized for sessionstartup to run + // successfully. + const { CrashMonitor } = ChromeUtils.importESModule( + "resource://gre/modules/CrashMonitor.sys.mjs" + ); + CrashMonitor.init(); + + // Start sessionstartup initialization. + SessionStartup.init(); +} + +// Compress the source file using lz4 and put the result to destination file. +// After that, source file is deleted. +async function writeCompressedFile(source, destination) { + let s = await IOUtils.read(source); + await IOUtils.write(destination, s, { compress: true }); + await IOUtils.remove(source); +} diff --git a/browser/components/sessionstore/test/unit/test_backup_once.js b/browser/components/sessionstore/test/unit/test_backup_once.js new file mode 100644 index 0000000000..db566491d5 --- /dev/null +++ b/browser/components/sessionstore/test/unit/test_backup_once.js @@ -0,0 +1,137 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { SessionWriter } = ChromeUtils.importESModule( + "resource:///modules/sessionstore/SessionWriter.sys.mjs" +); + +// Make sure that we have a profile before initializing SessionFile. +const profd = do_get_profile(); +const { SessionFile } = ChromeUtils.importESModule( + "resource:///modules/sessionstore/SessionFile.sys.mjs" +); +const Paths = SessionFile.Paths; + +// We need a XULAppInfo to initialize SessionFile +const { updateAppInfo } = ChromeUtils.importESModule( + "resource://testing-common/AppInfo.sys.mjs" +); +updateAppInfo({ + name: "SessionRestoreTest", + ID: "{230de50e-4cd1-11dc-8314-0800200c9a66}", + version: "1", + platformVersion: "", +}); + +add_setup(async function () { + let source = do_get_file("data/sessionstore_valid.js"); + source.copyTo(profd, "sessionstore.js"); + await writeCompressedFile(Paths.clean.replace("jsonlz4", "js"), Paths.clean); + + // Finish initialization of SessionFile + await SessionFile.read(); +}); + +function promise_check_exist(path, shouldExist) { + return (async function () { + info( + "Ensuring that " + path + (shouldExist ? " exists" : " does not exist") + ); + if ((await IOUtils.exists(path)) != shouldExist) { + throw new Error( + "File" + path + " should " + (shouldExist ? "exist" : "not exist") + ); + } + })(); +} + +function promise_check_contents(path, expect) { + return (async function () { + info("Checking whether " + path + " has the right contents"); + let actual = await IOUtils.readJSON(path, { + decompress: true, + }); + Assert.deepEqual( + actual, + expect, + `File ${path} contains the expected data.` + ); + })(); +} + +function generateFileContents(id) { + let url = `http://example.com/test_backup_once#${id}_${Math.random()}`; + return { windows: [{ tabs: [{ entries: [{ url }], index: 1 }] }] }; +} + +// Write to the store, and check that it creates: +// - $Path.recovery with the new data +// - $Path.nextUpgradeBackup with the old data +add_task(async function test_first_write_backup() { + let initial_content = generateFileContents("initial"); + let new_content = generateFileContents("test_1"); + + info("Before the first write, none of the files should exist"); + await promise_check_exist(Paths.backups, false); + + await IOUtils.makeDirectory(Paths.backups); + await IOUtils.writeJSON(Paths.clean, initial_content, { + compress: true, + }); + await SessionFile.write(new_content); + + info("After first write, a few files should have been created"); + await promise_check_exist(Paths.backups, true); + await promise_check_exist(Paths.clean, false); + await promise_check_exist(Paths.cleanBackup, true); + await promise_check_exist(Paths.recovery, true); + await promise_check_exist(Paths.recoveryBackup, false); + await promise_check_exist(Paths.nextUpgradeBackup, true); + + await promise_check_contents(Paths.recovery, new_content); + await promise_check_contents(Paths.nextUpgradeBackup, initial_content); +}); + +// Write to the store again, and check that +// - $Path.clean is not written +// - $Path.recovery contains the new data +// - $Path.recoveryBackup contains the previous data +add_task(async function test_second_write_no_backup() { + let new_content = generateFileContents("test_2"); + let previous_backup_content = await IOUtils.readJSON(Paths.recovery, { + decompress: true, + }); + + await IOUtils.remove(Paths.cleanBackup); + + await SessionFile.write(new_content); + + await promise_check_exist(Paths.backups, true); + await promise_check_exist(Paths.clean, false); + await promise_check_exist(Paths.cleanBackup, false); + await promise_check_exist(Paths.recovery, true); + await promise_check_exist(Paths.nextUpgradeBackup, true); + + await promise_check_contents(Paths.recovery, new_content); + await promise_check_contents(Paths.recoveryBackup, previous_backup_content); +}); + +// Make sure that we create $Paths.clean and remove $Paths.recovery* +// upon shutdown +add_task(async function test_shutdown() { + let output = generateFileContents("test_3"); + + await IOUtils.writeUTF8(Paths.recovery, "I should disappear"); + await IOUtils.writeUTF8(Paths.recoveryBackup, "I should also disappear"); + + await SessionWriter.write(output, { + isFinalWrite: true, + performShutdownCleanup: true, + }); + + Assert.ok(!(await IOUtils.exists(Paths.recovery))); + Assert.ok(!(await IOUtils.exists(Paths.recoveryBackup))); + await promise_check_contents(Paths.clean, output); +}); diff --git a/browser/components/sessionstore/test/unit/test_final_write_cleanup.js b/browser/components/sessionstore/test/unit/test_final_write_cleanup.js new file mode 100644 index 0000000000..503dd71420 --- /dev/null +++ b/browser/components/sessionstore/test/unit/test_final_write_cleanup.js @@ -0,0 +1,118 @@ +"use strict"; + +/** + * This test ensures that we correctly clean up the session state when + * writing with isFinalWrite, which is used on shutdown. It tests that each + * tab's shistory is capped to a maximum number of preceding and succeeding + * entries. + */ + +const { SessionWriter } = ChromeUtils.importESModule( + "resource:///modules/sessionstore/SessionWriter.sys.mjs" +); + +// Make sure that we have a profile before initializing SessionFile. +do_get_profile(); +const { + SessionFile: { Paths }, +} = ChromeUtils.importESModule( + "resource:///modules/sessionstore/SessionFile.sys.mjs" +); + +const MAX_ENTRIES = 9; +const URL = "http://example.com/#"; + +Cu.importGlobalProperties(["structuredClone"]); + +async function prepareWithLimit(back, fwd) { + SessionWriter.init("empty", false, Paths, { + maxSerializeBack: back, + maxSerializeForward: fwd, + maxUpgradeBackups: 3, + }); + await SessionWriter.wipe(); +} + +add_setup(async function () { + registerCleanupFunction(() => SessionWriter.wipe()); +}); + +function createSessionState(index) { + // Generate the tab state entries and set the one-based + // tab-state index to the middle session history entry. + let tabState = { entries: [], index }; + for (let i = 0; i < MAX_ENTRIES; i++) { + tabState.entries.push({ url: URL + i }); + } + + return { windows: [{ tabs: [tabState] }] }; +} + +async function writeAndParse(state, path, options = {}) { + // We clone here because `write` can change the data passed. + let data = structuredClone(state); + await SessionWriter.write(data, options); + return IOUtils.readJSON(path, { decompress: true }); +} + +add_task(async function test_shistory_cap_none() { + let state = createSessionState(5); + + // Don't limit the number of shistory entries. + await prepareWithLimit(-1, -1); + + // Check that no caps are applied. + let diskState = await writeAndParse(state, Paths.clean, { + isFinalWrite: true, + }); + Assert.deepEqual(state, diskState, "no cap applied"); +}); + +add_task(async function test_shistory_cap_middle() { + let state = createSessionState(5); + await prepareWithLimit(2, 3); + + // Cap is only applied on clean shutdown. + let diskState = await writeAndParse(state, Paths.recovery); + Assert.deepEqual(state, diskState, "no cap applied"); + + // Check that the right number of shistory entries was discarded + // and the shistory index updated accordingly. + diskState = await writeAndParse(state, Paths.clean, { isFinalWrite: true }); + let tabState = state.windows[0].tabs[0]; + tabState.entries = tabState.entries.slice(2, 8); + tabState.index = 3; + Assert.deepEqual(state, diskState, "cap applied"); +}); + +add_task(async function test_shistory_cap_lower_bound() { + let state = createSessionState(1); + await prepareWithLimit(5, 5); + + // Cap is only applied on clean shutdown. + let diskState = await writeAndParse(state, Paths.recovery); + Assert.deepEqual(state, diskState, "no cap applied"); + + // Check that the right number of shistory entries was discarded. + diskState = await writeAndParse(state, Paths.clean, { isFinalWrite: true }); + let tabState = state.windows[0].tabs[0]; + tabState.entries = tabState.entries.slice(0, 6); + Assert.deepEqual(state, diskState, "cap applied"); +}); + +add_task(async function test_shistory_cap_upper_bound() { + let state = createSessionState(MAX_ENTRIES); + await prepareWithLimit(5, 5); + + // Cap is only applied on clean shutdown. + let diskState = await writeAndParse(state, Paths.recovery); + Assert.deepEqual(state, diskState, "no cap applied"); + + // Check that the right number of shistory entries was discarded + // and the shistory index updated accordingly. + diskState = await writeAndParse(state, Paths.clean, { isFinalWrite: true }); + let tabState = state.windows[0].tabs[0]; + tabState.entries = tabState.entries.slice(3); + tabState.index = 6; + Assert.deepEqual(state, diskState, "cap applied"); +}); diff --git a/browser/components/sessionstore/test/unit/test_histogram_corrupt_files.js b/browser/components/sessionstore/test/unit/test_histogram_corrupt_files.js new file mode 100644 index 0000000000..2c469ed3b4 --- /dev/null +++ b/browser/components/sessionstore/test/unit/test_histogram_corrupt_files.js @@ -0,0 +1,117 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* + * The primary purpose of this test is to ensure that + * the sessionstore component records information about + * corrupted backup files into a histogram. + */ + +"use strict"; + +const Telemetry = Services.telemetry; +const HistogramId = "FX_SESSION_RESTORE_ALL_FILES_CORRUPT"; + +// Prepare the session file. +do_get_profile(); +const { SessionFile } = ChromeUtils.importESModule( + "resource:///modules/sessionstore/SessionFile.sys.mjs" +); + +/** + * A utility function for resetting the histogram and the contents + * of the backup directory. This will also compress the file using lz4 compression. + */ +function promise_reset_session(backups = {}) { + return (async function () { + // Reset the histogram. + Telemetry.getHistogramById(HistogramId).clear(); + + // Reset the contents of the backups directory + await IOUtils.makeDirectory(SessionFile.Paths.backups); + let basePath = do_get_cwd().path; + for (let key of SessionFile.Paths.loadOrder) { + if (backups.hasOwnProperty(key)) { + let path = backups[key]; + const fullPath = PathUtils.join(basePath, ...path); + let s = await IOUtils.read(fullPath); + await IOUtils.write(SessionFile.Paths[key], s, { + compress: true, + }); + } else { + await IOUtils.remove(SessionFile.Paths[key]); + } + } + })(); +} + +/** + * In order to use FX_SESSION_RESTORE_ALL_FILES_CORRUPT histogram + * it has to be registered in "toolkit/components/telemetry/Histograms.json". + * This test ensures that the histogram is registered and empty. + */ +add_task(async function test_ensure_histogram_exists_and_empty() { + let s = Telemetry.getHistogramById(HistogramId).snapshot(); + Assert.equal(s.sum, 0, "Initially, the sum of probes is 0"); +}); + +/** + * Makes sure that the histogram is negatively updated when no + * backup files are present. + */ +add_task(async function test_no_files_exist() { + // No session files are available to SessionFile. + await promise_reset_session(); + + await SessionFile.read(); + // Checking if the histogram is updated negatively + let h = Telemetry.getHistogramById(HistogramId); + let s = h.snapshot(); + Assert.equal(s.values[0], 1, "One probe for the 'false' bucket."); + Assert.equal(s.values[1], 0, "No probes in the 'true' bucket."); +}); + +/** + * Makes sure that the histogram is negatively updated when at least one + * backup file is not corrupted. + */ +add_task(async function test_one_file_valid() { + // Corrupting some backup files. + let invalidSession = ["data", "sessionstore_invalid.js"]; + let validSession = ["data", "sessionstore_valid.js"]; + await promise_reset_session({ + clean: invalidSession, + cleanBackup: validSession, + recovery: invalidSession, + recoveryBackup: invalidSession, + }); + + await SessionFile.read(); + // Checking if the histogram is updated negatively. + let h = Telemetry.getHistogramById(HistogramId); + let s = h.snapshot(); + Assert.equal(s.values[0], 1, "One probe for the 'false' bucket."); + Assert.equal(s.values[1], 0, "No probes in the 'true' bucket."); +}); + +/** + * Makes sure that the histogram is positively updated when all + * backup files are corrupted. + */ +add_task(async function test_all_files_corrupt() { + // Corrupting all backup files. + let invalidSession = ["data", "sessionstore_invalid.js"]; + await promise_reset_session({ + clean: invalidSession, + cleanBackup: invalidSession, + recovery: invalidSession, + recoveryBackup: invalidSession, + }); + + await SessionFile.read(); + // Checking if the histogram is positively updated. + let h = Telemetry.getHistogramById(HistogramId); + let s = h.snapshot(); + Assert.equal(s.values[1], 1, "One probe for the 'true' bucket."); + Assert.equal(s.values[0], 0, "No probes in the 'false' bucket."); +}); diff --git a/browser/components/sessionstore/test/unit/test_migration_lz4compression.js b/browser/components/sessionstore/test/unit/test_migration_lz4compression.js new file mode 100644 index 0000000000..4d9b700d8b --- /dev/null +++ b/browser/components/sessionstore/test/unit/test_migration_lz4compression.js @@ -0,0 +1,151 @@ +"use strict"; + +const { SessionWriter } = ChromeUtils.importESModule( + "resource:///modules/sessionstore/SessionWriter.sys.mjs" +); + +// Make sure that we have a profile before initializing SessionFile. +const profd = do_get_profile(); +const { SessionFile } = ChromeUtils.importESModule( + "resource:///modules/sessionstore/SessionFile.sys.mjs" +); +const Paths = SessionFile.Paths; + +// We need a XULAppInfo to initialize SessionFile +const { updateAppInfo } = ChromeUtils.importESModule( + "resource://testing-common/AppInfo.sys.mjs" +); +updateAppInfo({ + name: "SessionRestoreTest", + ID: "{230de50e-4cd1-11dc-8314-0800200c9a66}", + version: "1", + platformVersion: "", +}); + +function promise_check_exist(path, shouldExist) { + return (async function () { + info( + "Ensuring that " + path + (shouldExist ? " exists" : " does not exist") + ); + if ((await IOUtils.exists(path)) != shouldExist) { + throw new Error( + "File " + path + " should " + (shouldExist ? "exist" : "not exist") + ); + } + })(); +} + +function promise_check_contents(path, expect) { + return (async function () { + info("Checking whether " + path + " has the right contents"); + let actual = await IOUtils.readJSON(path, { + decompress: true, + }); + Assert.deepEqual( + actual, + expect, + `File ${path} contains the expected data.` + ); + })(); +} + +// Check whether the migration from .js to .jslz4 is correct. +add_task(async function test_migration() { + let source = do_get_file("data/sessionstore_valid.js"); + source.copyTo(profd, "sessionstore.js"); + + // Read the content of the session store file. + let parsed = await IOUtils.readJSON(Paths.clean.replace("jsonlz4", "js")); + + // Read the session file with .js extension. + let result = await SessionFile.read(); + + // Check whether the result is what we wanted. + equal(result.origin, "clean"); + equal(result.useOldExtension, true); + Assert.deepEqual( + result.parsed, + parsed, + "result.parsed contains expected data" + ); + + // Initiate a write to ensure we write the compressed version. + await SessionFile.write(parsed); + await promise_check_exist(Paths.backups, true); + await promise_check_exist(Paths.clean, false); + await promise_check_exist(Paths.cleanBackup, true); + await promise_check_exist(Paths.recovery, true); + await promise_check_exist(Paths.recoveryBackup, false); + await promise_check_exist(Paths.nextUpgradeBackup, true); + // The deprecated $Path.clean should exist. + await promise_check_exist(Paths.clean.replace("jsonlz4", "js"), true); + + await promise_check_contents(Paths.recovery, parsed); +}); + +add_task(async function test_startup_with_compressed_clean() { + let state = { windows: [] }; + + // Mare sure we have an empty profile dir. + await SessionFile.wipe(); + + // Populate session files to profile dir. + await IOUtils.writeJSON(Paths.clean, state, { + compress: true, + }); + await IOUtils.makeDirectory(Paths.backups); + await IOUtils.writeJSON(Paths.cleanBackup, state, { + compress: true, + }); + + // Initiate a read. + let result = await SessionFile.read(); + + // Make sure we read correct session file and its content. + equal(result.origin, "clean"); + equal(result.useOldExtension, false); + Assert.deepEqual( + state, + result.parsed, + "result.parsed contains expected data" + ); +}); + +add_task(async function test_empty_profile_dir() { + // Make sure that we have empty profile dir. + await SessionFile.wipe(); + await promise_check_exist(Paths.backups, false); + await promise_check_exist(Paths.clean, false); + await promise_check_exist(Paths.cleanBackup, false); + await promise_check_exist(Paths.recovery, false); + await promise_check_exist(Paths.recoveryBackup, false); + await promise_check_exist(Paths.nextUpgradeBackup, false); + await promise_check_exist(Paths.backups.replace("jsonlz4", "js"), false); + await promise_check_exist(Paths.clean.replace("jsonlz4", "js"), false); + await promise_check_exist(Paths.cleanBackup.replace("lz4", ""), false); + await promise_check_exist(Paths.recovery.replace("jsonlz4", "js"), false); + await promise_check_exist( + Paths.recoveryBackup.replace("jsonlz4", "js"), + false + ); + await promise_check_exist( + Paths.nextUpgradeBackup.replace("jsonlz4", "js"), + false + ); + + // Initiate a read and make sure that we are in empty state. + let result = await SessionFile.read(); + equal(result.origin, "empty"); + equal(result.noFilesFound, true); + + // Create a state to store. + let state = { windows: [] }; + await SessionWriter.write(state, { isFinalWrite: true }); + + // Check session files are created, but not deprecated ones. + await promise_check_exist(Paths.clean, true); + await promise_check_exist(Paths.clean.replace("jsonlz4", "js"), false); + + // Check session file' content is correct. + await promise_check_contents(Paths.clean, state); +}); diff --git a/browser/components/sessionstore/test/unit/test_startup_invalid_session.js b/browser/components/sessionstore/test/unit/test_startup_invalid_session.js new file mode 100644 index 0000000000..50960b1d43 --- /dev/null +++ b/browser/components/sessionstore/test/unit/test_startup_invalid_session.js @@ -0,0 +1,27 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +function run_test() { + let profd = do_get_profile(); + var SessionFile = ChromeUtils.importESModule( + "resource:///modules/sessionstore/SessionFile.sys.mjs" + ).SessionFile; + + let sourceSession = do_get_file("data/sessionstore_invalid.js"); + sourceSession.copyTo(profd, "sessionstore.js"); + + let sourceCheckpoints = do_get_file("data/sessionCheckpoints_all.json"); + sourceCheckpoints.copyTo(profd, "sessionCheckpoints.json"); + + // Compress sessionstore.js to sessionstore.jsonlz4 + // and remove sessionstore.js + let oldExtSessionFile = SessionFile.Paths.clean.replace("jsonlz4", "js"); + writeCompressedFile(oldExtSessionFile, SessionFile.Paths.clean).then(() => { + afterSessionStartupInitialization(function cb() { + Assert.equal(SessionStartup.sessionType, SessionStartup.NO_SESSION); + do_test_finished(); + }); + }); + + do_test_pending(); +} diff --git a/browser/components/sessionstore/test/unit/test_startup_nosession_async.js b/browser/components/sessionstore/test/unit/test_startup_nosession_async.js new file mode 100644 index 0000000000..259c393e63 --- /dev/null +++ b/browser/components/sessionstore/test/unit/test_startup_nosession_async.js @@ -0,0 +1,19 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Test SessionStartup.sessionType in the following scenario: +// - no sessionstore.js; +// - the session store has been loaded, so no need to go +// through the synchronous fallback + +function run_test() { + // Initialize the profile (the session startup uses it) + do_get_profile(); + + do_test_pending(); + + afterSessionStartupInitialization(function cb() { + Assert.equal(SessionStartup.sessionType, SessionStartup.NO_SESSION); + do_test_finished(); + }); +} diff --git a/browser/components/sessionstore/test/unit/test_startup_session_async.js b/browser/components/sessionstore/test/unit/test_startup_session_async.js new file mode 100644 index 0000000000..a61c9fe422 --- /dev/null +++ b/browser/components/sessionstore/test/unit/test_startup_session_async.js @@ -0,0 +1,32 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Test SessionStartup.sessionType in the following scenario: +// - valid sessionstore.js; +// - valid sessionCheckpoints.json with all checkpoints; +// - the session store has been loaded + +function run_test() { + let profd = do_get_profile(); + var SessionFile = ChromeUtils.importESModule( + "resource:///modules/sessionstore/SessionFile.sys.mjs" + ).SessionFile; + + let sourceSession = do_get_file("data/sessionstore_valid.js"); + sourceSession.copyTo(profd, "sessionstore.js"); + + let sourceCheckpoints = do_get_file("data/sessionCheckpoints_all.json"); + sourceCheckpoints.copyTo(profd, "sessionCheckpoints.json"); + + // Compress sessionstore.js to sessionstore.jsonlz4 + // and remove sessionstore.js + let oldExtSessionFile = SessionFile.Paths.clean.replace("jsonlz4", "js"); + writeCompressedFile(oldExtSessionFile, SessionFile.Paths.clean).then(() => { + afterSessionStartupInitialization(function cb() { + Assert.equal(SessionStartup.sessionType, SessionStartup.DEFER_SESSION); + do_test_finished(); + }); + }); + + do_test_pending(); +} diff --git a/browser/components/sessionstore/test/unit/xpcshell.ini b/browser/components/sessionstore/test/unit/xpcshell.ini new file mode 100644 index 0000000000..b5fadb609d --- /dev/null +++ b/browser/components/sessionstore/test/unit/xpcshell.ini @@ -0,0 +1,21 @@ +[DEFAULT] +head = head.js +tags = condprof +firefox-appdir = browser +skip-if = toolkit == 'android' # bug 1730213 +support-files = + data/sessionCheckpoints_all.json + data/sessionstore_invalid.js + data/sessionstore_valid.js + +[test_backup_once.js] +skip-if = condprof # 1769154 +[test_final_write_cleanup.js] +[test_histogram_corrupt_files.js] +[test_migration_lz4compression.js] +skip-if = condprof # 1769154 +[test_startup_nosession_async.js] +skip-if = condprof # 1769154 +[test_startup_session_async.js] +[test_startup_invalid_session.js] +skip-if = condprof # 1769154 -- cgit v1.2.3