From 5f8de423f190bbb79a62f804151bc24824fa32d8 Mon Sep 17 00:00:00 2001 From: "Matt A. Tobin" Date: Fri, 2 Feb 2018 04:16:08 -0500 Subject: Add m-esr52 at 52.6.0 --- browser/components/sessionstore/ContentRestore.jsm | 431 ++ .../sessionstore/DocShellCapabilities.jsm | 50 + browser/components/sessionstore/FrameTree.jsm | 254 ++ browser/components/sessionstore/GlobalState.jsm | 84 + browser/components/sessionstore/PageStyle.jsm | 100 + browser/components/sessionstore/PrivacyFilter.jsm | 135 + browser/components/sessionstore/PrivacyLevel.jsm | 64 + .../RecentlyClosedTabsAndWindowsMenuUtils.jsm | 214 + browser/components/sessionstore/RunState.jsm | 96 + browser/components/sessionstore/SessionCookies.jsm | 476 ++ browser/components/sessionstore/SessionFile.jsm | 399 ++ browser/components/sessionstore/SessionHistory.jsm | 428 ++ .../components/sessionstore/SessionMigration.jsm | 100 + browser/components/sessionstore/SessionSaver.jsm | 264 ++ browser/components/sessionstore/SessionStorage.jsm | 173 + browser/components/sessionstore/SessionStore.jsm | 4719 ++++++++++++++++++++ browser/components/sessionstore/SessionWorker.js | 381 ++ browser/components/sessionstore/SessionWorker.jsm | 25 + .../components/sessionstore/StartupPerformance.jsm | 234 + browser/components/sessionstore/TabAttributes.jsm | 74 + browser/components/sessionstore/TabState.jsm | 196 + browser/components/sessionstore/TabStateCache.jsm | 163 + .../components/sessionstore/TabStateFlusher.jsm | 184 + .../sessionstore/content/aboutSessionRestore.js | 362 ++ .../sessionstore/content/aboutSessionRestore.xhtml | 86 + .../sessionstore/content/content-sessionStore.js | 897 ++++ browser/components/sessionstore/jar.mn | 8 + browser/components/sessionstore/moz.build | 52 + .../components/sessionstore/nsISessionStartup.idl | 66 + .../components/sessionstore/nsISessionStore.idl | 220 + .../components/sessionstore/nsSessionStartup.js | 353 ++ browser/components/sessionstore/nsSessionStore.js | 39 + .../sessionstore/nsSessionStore.manifest | 15 + browser/components/sessionstore/test/.eslintrc.js | 7 + browser/components/sessionstore/test/browser.ini | 242 + .../sessionstore/test/browser_1234021.js | 18 + .../sessionstore/test/browser_1234021_page.html | 6 + .../test/browser_248970_b_perwindowpb.js | 166 + .../sessionstore/test/browser_248970_b_sample.html | 37 + .../components/sessionstore/test/browser_339445.js | 32 + .../sessionstore/test/browser_339445_sample.html | 18 + .../components/sessionstore/test/browser_345898.js | 44 + .../components/sessionstore/test/browser_350525.js | 102 + .../test/browser_354894_perwindowpb.js | 474 ++ .../components/sessionstore/test/browser_367052.js | 41 + .../components/sessionstore/test/browser_393716.js | 71 + .../sessionstore/test/browser_394759_basic.js | 92 + .../sessionstore/test/browser_394759_behavior.js | 76 + .../test/browser_394759_perwindowpb.js | 55 + .../sessionstore/test/browser_394759_purge.js | 130 + .../components/sessionstore/test/browser_423132.js | 59 + .../sessionstore/test/browser_423132_sample.html | 14 + .../components/sessionstore/test/browser_447951.js | 65 + .../sessionstore/test/browser_447951_sample.html | 5 + .../components/sessionstore/test/browser_454908.js | 47 + .../sessionstore/test/browser_454908_sample.html | 8 + .../components/sessionstore/test/browser_456342.js | 49 + .../sessionstore/test/browser_456342_sample.xhtml | 36 + .../components/sessionstore/test/browser_459906.js | 62 + .../sessionstore/test/browser_459906_empty.html | 3 + .../sessionstore/test/browser_459906_sample.html | 41 + .../components/sessionstore/test/browser_461634.js | 85 + .../components/sessionstore/test/browser_461743.js | 39 + .../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 | 53 + .../sessionstore/test/browser_463206_sample.html | 11 + .../components/sessionstore/test/browser_464199.js | 85 + .../sessionstore/test/browser_464620_a.html | 54 + .../sessionstore/test/browser_464620_a.js | 48 + .../sessionstore/test/browser_464620_b.html | 58 + .../sessionstore/test/browser_464620_b.js | 48 + .../sessionstore/test/browser_464620_xd.html | 5 + .../components/sessionstore/test/browser_465215.js | 28 + .../components/sessionstore/test/browser_465223.js | 45 + .../components/sessionstore/test/browser_466937.js | 42 + .../sessionstore/test/browser_466937_sample.html | 22 + .../test/browser_467409-backslashplosion.js | 74 + .../components/sessionstore/test/browser_477657.js | 60 + .../components/sessionstore/test/browser_480893.js | 47 + .../components/sessionstore/test/browser_485482.js | 37 + .../sessionstore/test/browser_485482_sample.html | 12 + .../components/sessionstore/test/browser_485563.js | 26 + .../components/sessionstore/test/browser_490040.js | 65 + .../components/sessionstore/test/browser_491168.js | 42 + .../components/sessionstore/test/browser_491577.js | 120 + .../components/sessionstore/test/browser_495495.js | 46 + .../components/sessionstore/test/browser_500328.js | 120 + .../components/sessionstore/test/browser_506482.js | 73 + .../components/sessionstore/test/browser_514751.js | 38 + .../components/sessionstore/test/browser_522375.js | 21 + .../components/sessionstore/test/browser_522545.js | 269 ++ .../components/sessionstore/test/browser_524745.js | 42 + .../components/sessionstore/test/browser_526613.js | 72 + .../components/sessionstore/test/browser_528776.js | 21 + .../components/sessionstore/test/browser_579868.js | 30 + .../components/sessionstore/test/browser_579879.js | 20 + .../components/sessionstore/test/browser_580512.js | 81 + .../components/sessionstore/test/browser_581937.js | 19 + .../sessionstore/test/browser_586068-apptabs.js | 58 + .../test/browser_586068-apptabs_ondemand.js | 53 + .../browser_586068-browser_state_interrupted.js | 113 + .../sessionstore/test/browser_586068-cascade.js | 54 + .../test/browser_586068-multi_window.js | 70 + .../sessionstore/test/browser_586068-reload.js | 54 + .../sessionstore/test/browser_586068-select.js | 69 + .../test/browser_586068-window_state.js | 59 + .../test/browser_586068-window_state_override.js | 59 + .../components/sessionstore/test/browser_586147.js | 52 + .../components/sessionstore/test/browser_588426.js | 41 + .../components/sessionstore/test/browser_589246.js | 242 + .../components/sessionstore/test/browser_590268.js | 137 + .../components/sessionstore/test/browser_590563.js | 74 + .../test/browser_595601-restore_hidden.js | 112 + .../components/sessionstore/test/browser_597071.js | 36 + .../components/sessionstore/test/browser_599909.js | 120 + .../components/sessionstore/test/browser_600545.js | 89 + .../components/sessionstore/test/browser_601955.js | 54 + .../components/sessionstore/test/browser_607016.js | 98 + .../test/browser_615394-SSWindowState_events.js | 361 ++ .../components/sessionstore/test/browser_618151.js | 65 + .../components/sessionstore/test/browser_623779.js | 13 + .../components/sessionstore/test/browser_624727.js | 35 + .../components/sessionstore/test/browser_625016.js | 82 + .../components/sessionstore/test/browser_628270.js | 52 + .../components/sessionstore/test/browser_635418.js | 55 + .../components/sessionstore/test/browser_636279.js | 101 + .../components/sessionstore/test/browser_637020.js | 66 + .../sessionstore/test/browser_637020_slow.sjs | 21 + .../test/browser_644409-scratchpads.js | 68 + .../components/sessionstore/test/browser_645428.js | 22 + .../components/sessionstore/test/browser_659591.js | 33 + .../components/sessionstore/test/browser_662743.js | 110 + .../sessionstore/test/browser_662743_sample.html | 15 + .../components/sessionstore/test/browser_662812.js | 36 + .../test/browser_665702-state_session.js | 24 + .../components/sessionstore/test/browser_682507.js | 16 + .../components/sessionstore/test/browser_687710.js | 44 + .../sessionstore/test/browser_687710_2.js | 64 + .../components/sessionstore/test/browser_694378.js | 33 + .../components/sessionstore/test/browser_701377.js | 41 + .../components/sessionstore/test/browser_705597.js | 58 + .../components/sessionstore/test/browser_707862.js | 61 + .../components/sessionstore/test/browser_739531.js | 47 + .../sessionstore/test/browser_739531_sample.html | 25 + .../components/sessionstore/test/browser_739805.js | 41 + .../test/browser_819510_perwindowpb.js | 120 + .../components/sessionstore/test/browser_911547.js | 63 + .../sessionstore/test/browser_911547_sample.html | 19 + .../test/browser_911547_sample.html^headers^ | 1 + .../test/browser_aboutPrivateBrowsing.js | 21 + .../test/browser_aboutSessionRestore.js | 55 + .../test/browser_async_duplicate_tab.js | 78 + .../sessionstore/test/browser_async_flushes.js | 113 + .../sessionstore/test/browser_async_remove_tab.js | 242 + .../test/browser_async_window_flushing.js | 178 + .../sessionstore/test/browser_attributes.js | 73 + .../test/browser_background_tab_crash.js | 221 + .../sessionstore/test/browser_backup_recovery.js | 206 + .../sessionstore/test/browser_broadcast.js | 131 + .../sessionstore/test/browser_capabilities.js | 76 + .../sessionstore/test/browser_cleaner.js | 157 + .../sessionstore/test/browser_cookies.js | 173 + .../sessionstore/test/browser_cookies.sjs | 21 + .../sessionstore/test/browser_crashedTabs.js | 462 ++ .../sessionstore/test/browser_dying_cache.js | 66 + .../sessionstore/test/browser_dynamic_frames.js | 87 + .../test/browser_forget_async_closings.js | 144 + .../test/browser_form_restore_events.js | 63 + .../test/browser_form_restore_events_sample.html | 99 + .../sessionstore/test/browser_formdata.js | 194 + .../sessionstore/test/browser_formdata_cc.js | 79 + .../sessionstore/test/browser_formdata_format.js | 113 + .../test/browser_formdata_format_sample.html | 7 + .../sessionstore/test/browser_formdata_sample.html | 20 + .../sessionstore/test/browser_formdata_xpath.js | 151 + .../test/browser_formdata_xpath_sample.html | 37 + .../sessionstore/test/browser_frame_history.js | 170 + .../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 | 10 + .../test/browser_frame_history_index2.html | 4 + .../test/browser_frame_history_index_blank.html | 5 + .../sessionstore/test/browser_frametree.js | 131 + .../test/browser_frametree_sample.html | 8 + .../test/browser_frametree_sample_frameset.html | 11 + .../sessionstore/test/browser_global_store.js | 45 + .../sessionstore/test/browser_history_persist.js | 93 + .../sessionstore/test/browser_label_and_icon.js | 53 + .../sessionstore/test/browser_merge_closed_tabs.js | 71 + .../test/browser_multiple_navigateAndRestore.js | 36 + .../test/browser_newtab_userTypedValue.js | 72 + .../sessionstore/test/browser_pageStyle.js | 89 + .../test/browser_pageStyle_sample.html | 16 + .../test/browser_pageStyle_sample_nested.html | 9 + .../sessionstore/test/browser_page_title.js | 45 + .../test/browser_parentProcessRestoreHash.js | 95 + .../sessionstore/test/browser_pending_tabs.js | 35 + .../sessionstore/test/browser_privatetabs.js | 133 + .../sessionstore/test/browser_purge_shistory.js | 59 + .../test/browser_remoteness_flip_on_restore.js | 342 ++ .../sessionstore/test/browser_replace_load.js | 52 + .../browser_restore_cookies_noOriginAttributes.js | 171 + .../sessionstore/test/browser_restore_redirect.js | 69 + .../test/browser_revive_crashed_bg_tabs.js | 56 + .../sessionstore/test/browser_scrollPositions.js | 153 + .../test/browser_scrollPositionsReaderMode.js | 67 + .../browser_scrollPositions_readerModeArticle.html | 26 + .../test/browser_scrollPositions_sample.html | 8 + .../browser_scrollPositions_sample_frameset.html | 11 + .../test/browser_send_async_message_oom.js | 75 + .../sessionstore/test/browser_sessionHistory.js | 240 + .../test/browser_sessionHistory_slow.sjs | 21 + .../sessionstore/test/browser_sessionStorage.html | 27 + .../sessionstore/test/browser_sessionStorage.js | 188 + .../test/browser_sessionStorage_size.js | 51 + .../test/browser_sessionStoreContainer.js | 141 + .../sessionstore/test/browser_swapDocShells.js | 35 + .../sessionstore/test/browser_switch_remoteness.js | 49 + .../sessionstore/test/browser_undoCloseById.js | 118 + .../test/browser_unrestored_crashedTabs.js | 69 + .../sessionstore/test/browser_upgrade_backup.js | 134 + .../test/browser_windowRestore_perwindowpb.js | 26 + .../test/browser_windowStateContainer.js | 122 + .../components/sessionstore/test/content-forms.js | 133 + browser/components/sessionstore/test/content.js | 222 + browser/components/sessionstore/test/head.js | 564 +++ .../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 + .../components/sessionstore/test/unit/.eslintrc.js | 7 + .../test/unit/data/sessionCheckpoints_all.json | 1 + .../test/unit/data/sessionstore_invalid.js | 3 + .../test/unit/data/sessionstore_valid.js | 3 + browser/components/sessionstore/test/unit/head.js | 32 + .../sessionstore/test/unit/test_backup_once.js | 130 + .../test/unit/test_histogram_corrupt_files.js | 114 + .../test/unit/test_shutdown_cleanup.js | 127 + .../test/unit/test_startup_invalid_session.js | 21 + .../test/unit/test_startup_nosession_async.js | 22 + .../test/unit/test_startup_session_async.js | 27 + .../components/sessionstore/test/unit/xpcshell.ini | 16 + 247 files changed, 27019 insertions(+) create mode 100644 browser/components/sessionstore/ContentRestore.jsm create mode 100644 browser/components/sessionstore/DocShellCapabilities.jsm create mode 100644 browser/components/sessionstore/FrameTree.jsm create mode 100644 browser/components/sessionstore/GlobalState.jsm create mode 100644 browser/components/sessionstore/PageStyle.jsm create mode 100644 browser/components/sessionstore/PrivacyFilter.jsm create mode 100644 browser/components/sessionstore/PrivacyLevel.jsm create mode 100644 browser/components/sessionstore/RecentlyClosedTabsAndWindowsMenuUtils.jsm create mode 100644 browser/components/sessionstore/RunState.jsm create mode 100644 browser/components/sessionstore/SessionCookies.jsm create mode 100644 browser/components/sessionstore/SessionFile.jsm create mode 100644 browser/components/sessionstore/SessionHistory.jsm create mode 100644 browser/components/sessionstore/SessionMigration.jsm create mode 100644 browser/components/sessionstore/SessionSaver.jsm create mode 100644 browser/components/sessionstore/SessionStorage.jsm create mode 100644 browser/components/sessionstore/SessionStore.jsm create mode 100644 browser/components/sessionstore/SessionWorker.js create mode 100644 browser/components/sessionstore/SessionWorker.jsm create mode 100644 browser/components/sessionstore/StartupPerformance.jsm create mode 100644 browser/components/sessionstore/TabAttributes.jsm create mode 100644 browser/components/sessionstore/TabState.jsm create mode 100644 browser/components/sessionstore/TabStateCache.jsm create mode 100644 browser/components/sessionstore/TabStateFlusher.jsm 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/nsISessionStartup.idl create mode 100644 browser/components/sessionstore/nsISessionStore.idl create mode 100644 browser/components/sessionstore/nsSessionStartup.js create mode 100644 browser/components/sessionstore/nsSessionStore.js create mode 100644 browser/components/sessionstore/nsSessionStore.manifest create mode 100644 browser/components/sessionstore/test/.eslintrc.js 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_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_599909.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.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_644409-scratchpads.js 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_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_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_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_cookies.js create mode 100644 browser/components/sessionstore/test/browser_cookies.sjs create mode 100644 browser/components/sessionstore/test/browser_crashedTabs.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_forget_async_closings.js create mode 100644 browser/components/sessionstore/test/browser_form_restore_events.js create mode 100644 browser/components/sessionstore/test/browser_form_restore_events_sample.html 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_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 100755 browser/components/sessionstore/test/browser_frame_history_a.html create mode 100755 browser/components/sessionstore/test/browser_frame_history_b.html create mode 100755 browser/components/sessionstore/test/browser_frame_history_c.html create mode 100755 browser/components/sessionstore/test/browser_frame_history_c1.html create mode 100755 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_global_store.js create mode 100644 browser/components/sessionstore/test/browser_history_persist.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_multiple_navigateAndRestore.js create mode 100644 browser/components/sessionstore/test/browser_newtab_userTypedValue.js create mode 100644 browser/components/sessionstore/test/browser_pageStyle.js create mode 100644 browser/components/sessionstore/test/browser_pageStyle_sample.html create mode 100644 browser/components/sessionstore/test/browser_pageStyle_sample_nested.html 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_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_replace_load.js create mode 100644 browser/components/sessionstore/test/browser_restore_cookies_noOriginAttributes.js create mode 100644 browser/components/sessionstore/test/browser_restore_redirect.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_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_swapDocShells.js create mode 100644 browser/components/sessionstore/test/browser_switch_remoteness.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_windowRestore_perwindowpb.js create mode 100644 browser/components/sessionstore/test/browser_windowStateContainer.js create mode 100644 browser/components/sessionstore/test/content-forms.js create mode 100644 browser/components/sessionstore/test/content.js create mode 100644 browser/components/sessionstore/test/head.js 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/.eslintrc.js 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_histogram_corrupt_files.js create mode 100644 browser/components/sessionstore/test/unit/test_shutdown_cleanup.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.jsm b/browser/components/sessionstore/ContentRestore.jsm new file mode 100644 index 000000000..976016770 --- /dev/null +++ b/browser/components/sessionstore/ContentRestore.jsm @@ -0,0 +1,431 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public +* License, v. 2.0. If a copy of the MPL was not distributed with this file, +* You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +this.EXPORTED_SYMBOLS = ["ContentRestore"]; + +const Cu = Components.utils; +const Ci = Components.interfaces; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm", this); + +XPCOMUtils.defineLazyModuleGetter(this, "DocShellCapabilities", + "resource:///modules/sessionstore/DocShellCapabilities.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "FormData", + "resource://gre/modules/FormData.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "PageStyle", + "resource:///modules/sessionstore/PageStyle.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "ScrollPosition", + "resource://gre/modules/ScrollPosition.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "SessionHistory", + "resource:///modules/sessionstore/SessionHistory.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "SessionStorage", + "resource:///modules/sessionstore/SessionStorage.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Utils", + "resource://gre/modules/sessionstore/Utils.jsm"); + +/** + * This module implements the content side of session restoration. The chrome + * side is handled by SessionStore.jsm. The functions in this module are called + * by content-sessionStore.js based on messages received from SessionStore.jsm + * (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.jsm, 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.jsm 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. + */ +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, pageStyle, 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.setCurrentURI(Utils.makeURI(uri)); + } + + 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.addSHistoryListener(listener); + this._historyListener = listener; + + // Make sure to reset the capabilities and attributes in case this tab gets + // reused. + let disallow = new Set(tabData.disallow && tabData.disallow.split(",")); + DocShellCapabilities.restore(this.docShell, disallow); + + if (tabData.storage && this.docShell instanceof Ci.nsIDocShell) { + SessionStorage.restore(this.docShell, tabData.storage); + delete tabData.storage; + } + + // 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: function (loadArguments, isRemotenessUpdate, finishCallback) { + let tabData = this._tabData; + this._tabData = null; + + let webNavigation = this.docShell.QueryInterface(Ci.nsIWebNavigation); + let history = webNavigation.sessionHistory; + + // 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.setCurrentURI(Utils.makeURI("about:blank")); + } + + try { + if (loadArguments) { + // A load has been redirected to a new process so get history into the + // same state it was before the load started then trigger the load. + let referrer = loadArguments.referrer ? + Utils.makeURI(loadArguments.referrer) : null; + let referrerPolicy = ('referrerPolicy' in loadArguments + ? loadArguments.referrerPolicy + : Ci.nsIHttpChannel.REFERRER_POLICY_DEFAULT); + let postData = loadArguments.postData ? + Utils.makeInputStream(loadArguments.postData) : null; + + if (loadArguments.userContextId) { + webNavigation.setOriginAttributesBeforeLoading({ userContextId: loadArguments.userContextId }); + } + + webNavigation.loadURIWithOptions(loadArguments.uri, loadArguments.flags, + referrer, referrerPolicy, postData, + null, null); + } 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. + webNavigation.loadURI(tabData.userTypedValue, + Ci.nsIWebNavigation.LOAD_FLAGS_ALLOW_THIRD_PARTY_FIXUP, + null, null, null); + } else if (tabData.entries.length) { + // Stash away the data we need for restoreDocument. + let activeIndex = tabData.index - 1; + this._restoringDocument = {entry: tabData.entries[activeIndex] || {}, + formdata: tabData.formdata || {}, + pageStyle: tabData.pageStyle || {}, + 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. + history.reloadCurrentEntry(); + } else { + // If there's nothing to restore, we should still blank the page. + webNavigation.loadURI("about:blank", + Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_HISTORY, + null, null, null); + } + + 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; + } + }, + + /** + * 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. + */ + restoreDocument: function () { + if (!this._restoringDocument) { + return; + } + let {entry, pageStyle, formdata, scrollPositions} = this._restoringDocument; + this._restoringDocument = null; + + let window = this.docShell.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindow); + + PageStyle.restoreTree(this.docShell, pageStyle); + FormData.restoreTree(window, formdata); + ScrollPosition.restoreTree(window, scrollPositions); + }, + + /** + * 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: function () { + 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.jsm so that it can restore the content immediately. + */ +function HistoryListener(docShell, callback) { + let webNavigation = docShell.QueryInterface(Ci.nsIWebNavigation); + webNavigation.sessionHistory.addSHistoryListener(this); + + this.webNavigation = webNavigation; + this.callback = callback; +} +HistoryListener.prototype = { + QueryInterface: XPCOMUtils.generateQI([ + Ci.nsISHistoryListener, + Ci.nsISupportsWeakReference + ]), + + uninstall: function () { + let shistory = this.webNavigation.sessionHistory; + if (shistory) { + shistory.removeSHistoryListener(this); + } + }, + + OnHistoryGoBack: function(backURI) { return true; }, + OnHistoryGoForward: function(forwardURI) { return true; }, + OnHistoryGotoIndex: function(index, gotoURI) { return true; }, + OnHistoryPurge: function(numEntries) { return true; }, + OnHistoryReplaceEntry: function(index) {}, + + // 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.setCurrentURI(Utils.makeURI("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 flags = Ci.nsIWebNavigation.LOAD_FLAGS_ALLOW_THIRD_PARTY_FIXUP; + this.webNavigation.loadURI(newURI.spec, flags, null, null, null); + }, + + OnHistoryReload(reloadURI, reloadFlags) { + this.callback(); + + // Cancel the load. + return false; + }, +} + +/** + * This class informs SessionStore.jsm whenever the network requests for a + * restoring page have completely finished. We only restore three tabs + * simultaneously, so this is the signal for SessionStore.jsm 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: XPCOMUtils.generateQI([ + Ci.nsIWebProgressListener, + Ci.nsISupportsWeakReference + ]), + + uninstall: function() { + this.webProgress.removeProgressListener(this); + }, + + onStateChange: function(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(); + } + }, + + onLocationChange: function() {}, + onProgressChange: function() {}, + onStatusChange: function() {}, + onSecurityChange: function() {}, +}; diff --git a/browser/components/sessionstore/DocShellCapabilities.jsm b/browser/components/sessionstore/DocShellCapabilities.jsm new file mode 100644 index 000000000..098aae86f --- /dev/null +++ b/browser/components/sessionstore/DocShellCapabilities.jsm @@ -0,0 +1,50 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public +* License, v. 2.0. If a copy of the MPL was not distributed with this file, +* You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +this.EXPORTED_SYMBOLS = ["DocShellCapabilities"]; + +/** + * The external API exported by this module. + */ +this.DocShellCapabilities = Object.freeze({ + collect: function (docShell) { + return DocShellCapabilitiesInternal.collect(docShell); + }, + + restore: function (docShell, disallow) { + return DocShellCapabilitiesInternal.restore(docShell, disallow); + }, +}); + +/** + * Internal functionality to save and restore the docShell.allow* properties. + */ +var DocShellCapabilitiesInternal = { + // List of docShell capabilities to (re)store. These are automatically + // retrieved from a given docShell if not already collected before. + // This is made so they're automatically in sync with all nsIDocShell.allow* + // properties. + caps: null, + + allCapabilities: function (docShell) { + if (!this.caps) { + let keys = Object.keys(docShell); + this.caps = keys.filter(k => k.startsWith("allow")).map(k => k.slice(5)); + } + return this.caps; + }, + + collect: function (docShell) { + let caps = this.allCapabilities(docShell); + return caps.filter(cap => !docShell["allow" + cap]); + }, + + restore: function (docShell, disallow) { + let caps = this.allCapabilities(docShell); + for (let cap of caps) + docShell["allow" + cap] = !disallow.has(cap); + }, +}; diff --git a/browser/components/sessionstore/FrameTree.jsm b/browser/components/sessionstore/FrameTree.jsm new file mode 100644 index 000000000..e8ed12a8f --- /dev/null +++ b/browser/components/sessionstore/FrameTree.jsm @@ -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/. */ + +"use strict"; + +this.EXPORTED_SYMBOLS = ["FrameTree"]; + +const Cu = Components.utils; +const Ci = Components.interfaces; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm", this); + +const EXPORTED_METHODS = ["addObserver", "contains", "map", "forEach"]; + +/** + * A FrameTree represents all frames that were reachable when the document + * was loaded. We use this information to ignore frames when collecting + * sessionstore data as we can't currently restore anything for frames that + * have been created dynamically after or at the load event. + * + * @constructor + */ +function FrameTree(chromeGlobal) { + let internal = new FrameTreeInternal(chromeGlobal); + let external = {}; + + for (let method of EXPORTED_METHODS) { + external[method] = internal[method].bind(internal); + } + + return Object.freeze(external); +} + +/** + * The internal frame tree API that the public one points to. + * + * @constructor + */ +function FrameTreeInternal(chromeGlobal) { + // A WeakMap that uses frames (DOMWindows) as keys and their initial indices + // in their parents' child lists as values. Suppose we have a root frame with + // three subframes i.e. a page with three iframes. The WeakMap would have + // four entries and look as follows: + // + // root -> 0 + // subframe1 -> 0 + // subframe2 -> 1 + // subframe3 -> 2 + // + // Should one of the subframes disappear we will stop collecting data for it + // as |this._frames.has(frame) == false|. All other subframes will maintain + // their initial indices to ensure we can restore frame data appropriately. + this._frames = new WeakMap(); + + // The Set of observers that will be notified when the frame changes. + this._observers = new Set(); + + // The chrome global we use to retrieve the current DOMWindow. + this._chromeGlobal = chromeGlobal; + + // Register a web progress listener to be notified about new page loads. + let docShell = chromeGlobal.docShell; + let ifreq = docShell.QueryInterface(Ci.nsIInterfaceRequestor); + let webProgress = ifreq.getInterface(Ci.nsIWebProgress); + webProgress.addProgressListener(this, Ci.nsIWebProgress.NOTIFY_STATE_DOCUMENT); +} + +FrameTreeInternal.prototype = { + + // Returns the docShell's current global. + get content() { + return this._chromeGlobal.content; + }, + + /** + * Adds a given observer |obs| to the set of observers that will be notified + * when the frame tree is reset (when a new document starts loading) or + * recollected (when a document finishes loading). + * + * @param obs (object) + */ + addObserver: function (obs) { + this._observers.add(obs); + }, + + /** + * Notifies all observers that implement the given |method|. + * + * @param method (string) + */ + notifyObservers: function (method) { + for (let obs of this._observers) { + if (obs.hasOwnProperty(method)) { + obs[method](); + } + } + }, + + /** + * Checks whether a given |frame| is contained in the collected frame tree. + * If it is not, this indicates that we should not collect data for it. + * + * @param frame (nsIDOMWindow) + * @return bool + */ + contains: function (frame) { + return this._frames.has(frame); + }, + + /** + * Recursively applies the given function |cb| to the stored frame tree. Use + * this method to collect sessionstore data for all reachable frames stored + * in the frame tree. + * + * If a given function |cb| returns a value, it must be an object. It may + * however return "null" to indicate that there is no data to be stored for + * the given frame. + * + * The object returned by |cb| cannot have any property named "children" as + * that is used to store information about subframes in the tree returned + * by |map()| and might be overridden. + * + * @param cb (function) + * @return object + */ + map: function (cb) { + let frames = this._frames; + + function walk(frame) { + let obj = cb(frame) || {}; + + if (frames.has(frame)) { + let children = []; + + Array.forEach(frame.frames, subframe => { + // Don't collect any data if the frame is not contained in the + // initial frame tree. It's a dynamic frame added later. + if (!frames.has(subframe)) { + return; + } + + // Retrieve the frame's original position in its parent's child list. + let index = frames.get(subframe); + + // Recursively collect data for the current subframe. + let result = walk(subframe, cb); + if (result && Object.keys(result).length) { + children[index] = result; + } + }); + + if (children.length) { + obj.children = children; + } + } + + return Object.keys(obj).length ? obj : null; + } + + return walk(this.content); + }, + + /** + * Applies the given function |cb| to all frames stored in the tree. Use this + * method if |map()| doesn't suit your needs and you want more control over + * how data is collected. + * + * @param cb (function) + * This callback receives the current frame as the only argument. + */ + forEach: function (cb) { + let frames = this._frames; + + function walk(frame) { + cb(frame); + + if (!frames.has(frame)) { + return; + } + + Array.forEach(frame.frames, subframe => { + if (frames.has(subframe)) { + cb(subframe); + } + }); + } + + walk(this.content); + }, + + /** + * Stores a given |frame| and its children in the frame tree. + * + * @param frame (nsIDOMWindow) + * @param index (int) + * The index in the given frame's parent's child list. + */ + collect: function (frame, index = 0) { + // Mark the given frame as contained in the frame tree. + this._frames.set(frame, index); + + // Mark the given frame's subframes as contained in the tree. + Array.forEach(frame.frames, this.collect, this); + }, + + /** + * @see nsIWebProgressListener.onStateChange + * + * We want to be notified about: + * - new documents that start loading to clear the current frame tree; + * - completed document loads to recollect reachable frames. + */ + onStateChange: function (webProgress, request, stateFlags, status) { + // Ignore state changes for subframes because we're only interested in the + // top-document starting or stopping its load. We thus only care about any + // changes to the root of the frame tree, not to any of its nodes/leafs. + if (!webProgress.isTopLevel || webProgress.DOMWindow != this.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._chromeGlobal.docShell.hasLoadedNonBlankURI) { + return; + } + + if (stateFlags & Ci.nsIWebProgressListener.STATE_START) { + // Clear the list of frames until we can recollect it. + this._frames = new WeakMap(); + + // Notify observers that the frame tree has been reset. + this.notifyObservers("onFrameTreeReset"); + } else if (stateFlags & Ci.nsIWebProgressListener.STATE_STOP) { + // The document and its resources have finished loading. + this.collect(webProgress.DOMWindow); + + // Notify observers that the frame tree has been reset. + this.notifyObservers("onFrameTreeCollected"); + } + }, + + // Unused nsIWebProgressListener methods. + onLocationChange: function () {}, + onProgressChange: function () {}, + onSecurityChange: function () {}, + onStatusChange: function () {}, + + QueryInterface: XPCOMUtils.generateQI([Ci.nsIWebProgressListener, + Ci.nsISupportsWeakReference]) +}; diff --git a/browser/components/sessionstore/GlobalState.jsm b/browser/components/sessionstore/GlobalState.jsm new file mode 100644 index 000000000..ac2d7c81b --- /dev/null +++ b/browser/components/sessionstore/GlobalState.jsm @@ -0,0 +1,84 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +this.EXPORTED_SYMBOLS = ["GlobalState"]; + +const EXPORTED_METHODS = ["getState", "clear", "get", "set", "delete", "setFromState"]; +/** + * Module that contains global session data. + */ +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: function() { + return this.state; + }, + + /** + * Clear all currently stored global state. + */ + clear: function() { + 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: function(aKey) { + return this.state[aKey] || ""; + }, + + /** + * Set a global value. + * + * @param aKey + * A key to store the value under. + */ + set: function(aKey, aStringValue) { + this.state[aKey] = aStringValue; + }, + + /** + * Delete a global value. + * + * @param aKey + * A key to delete the value for. + */ + delete: function(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: function (aState) { + this.state = (aState && aState.global) || {}; + } +}; diff --git a/browser/components/sessionstore/PageStyle.jsm b/browser/components/sessionstore/PageStyle.jsm new file mode 100644 index 000000000..0424ef6b1 --- /dev/null +++ b/browser/components/sessionstore/PageStyle.jsm @@ -0,0 +1,100 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public +* License, v. 2.0. If a copy of the MPL was not distributed with this file, +* You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +this.EXPORTED_SYMBOLS = ["PageStyle"]; + +const Ci = Components.interfaces; + +/** + * The external API exported by this module. + */ +this.PageStyle = Object.freeze({ + collect: function (docShell, frameTree) { + return PageStyleInternal.collect(docShell, frameTree); + }, + + restoreTree: function (docShell, data) { + PageStyleInternal.restoreTree(docShell, data); + } +}); + +// Signifies that author style level is disabled for the page. +const NO_STYLE = "_nostyle"; + +var PageStyleInternal = { + /** + * Collects the selected style sheet sets for all reachable frames. + */ + collect: function (docShell, frameTree) { + let result = frameTree.map(({document: doc}) => { + let style; + + if (doc) { + // http://dev.w3.org/csswg/cssom/#persisting-the-selected-css-style-sheet-set + style = doc.selectedStyleSheetSet || doc.lastStyleSheetSet; + } + + return style ? {pageStyle: style} : null; + }); + + let markupDocumentViewer = + docShell.contentViewer; + + if (markupDocumentViewer.authorStyleDisabled) { + result = result || {}; + result.disabled = true; + } + + return result && Object.keys(result).length ? result : null; + }, + + /** + * Restores pageStyle data for the current frame hierarchy starting at the + * |docShell's| current DOMWindow using the given pageStyle |data|. + * + * Warning: If the current frame hierarchy doesn't match that of the given + * |data| object we will silently discard data for unreachable frames. We may + * as well assign page styles to the wrong frames if some were reordered or + * removed. + * + * @param docShell (nsIDocShell) + * @param data (object) + * { + * disabled: true, // when true, author styles will be disabled + * pageStyle: "Dusk", + * children: [ + * null, + * {pageStyle: "Mozilla", children: [ ... ]} + * ] + * } + */ + restoreTree: function (docShell, data) { + let disabled = data.disabled || false; + let markupDocumentViewer = + docShell.contentViewer; + markupDocumentViewer.authorStyleDisabled = disabled; + + function restoreFrame(root, data) { + if (data.hasOwnProperty("pageStyle")) { + root.document.selectedStyleSheetSet = data.pageStyle; + } + + if (!data.hasOwnProperty("children")) { + return; + } + + let frames = root.frames; + data.children.forEach((child, index) => { + if (child && index < frames.length) { + restoreFrame(frames[index], child); + } + }); + } + + let ifreq = docShell.QueryInterface(Ci.nsIInterfaceRequestor); + restoreFrame(ifreq.getInterface(Ci.nsIDOMWindow), data); + } +}; diff --git a/browser/components/sessionstore/PrivacyFilter.jsm b/browser/components/sessionstore/PrivacyFilter.jsm new file mode 100644 index 000000000..88713b402 --- /dev/null +++ b/browser/components/sessionstore/PrivacyFilter.jsm @@ -0,0 +1,135 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public +* License, v. 2.0. If a copy of the MPL was not distributed with this file, +* You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +this.EXPORTED_SYMBOLS = ["PrivacyFilter"]; + +const Cu = Components.utils; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm", this); + +XPCOMUtils.defineLazyModuleGetter(this, "PrivacyLevel", + "resource:///modules/sessionstore/PrivacyLevel.jsm"); + +/** + * A module that provides methods to filter various kinds of data collected + * from a tab by the current privacy level as set by the user. + */ +this.PrivacyFilter = Object.freeze({ + /** + * Filters the given (serialized) session storage |data| according to the + * current privacy level and returns a new object containing only data that + * we're allowed to store. + * + * @param data The session storage data as collected from a tab. + * @return object + */ + filterSessionStorageData: function (data) { + let retval = {}; + + for (let host of Object.keys(data)) { + if (PrivacyLevel.check(host)) { + retval[host] = data[host]; + } + } + + return Object.keys(retval).length ? retval : null; + }, + + /** + * Filters the given (serialized) form |data| according to the current + * privacy level and returns a new object containing only data that we're + * allowed to store. + * + * @param data The form data as collected from a tab. + * @return object + */ + filterFormData: function (data) { + // If the given form data object has an associated URL that we are not + // allowed to store data for, bail out. We explicitly discard data for any + // children as well even if storing data for those frames would be allowed. + if (data.url && !PrivacyLevel.check(data.url)) { + return; + } + + let retval = {}; + + for (let key of Object.keys(data)) { + if (key === "children") { + let recurse = child => this.filterFormData(child); + let children = data.children.map(recurse).filter(child => child); + + if (children.length) { + retval.children = children; + } + // Only copy keys other than "children" if we have a valid URL in + // data.url and we thus passed the privacy level check. + } else if (data.url) { + retval[key] = data[key]; + } + } + + return Object.keys(retval).length ? retval : null; + }, + + /** + * Removes any private windows and tabs from a given browser state object. + * + * @param browserState (object) + * The browser state for which we remove any private windows and tabs. + * The given object will be modified. + */ + filterPrivateWindowsAndTabs: function (browserState) { + // Remove private opened windows. + for (let i = browserState.windows.length - 1; i >= 0; i--) { + let win = browserState.windows[i]; + + if (win.isPrivate) { + browserState.windows.splice(i, 1); + + if (browserState.selectedWindow >= i) { + browserState.selectedWindow--; + } + } else { + // Remove private tabs from all open non-private windows. + this.filterPrivateTabs(win); + } + } + + // Remove private closed windows. + browserState._closedWindows = + browserState._closedWindows.filter(win => !win.isPrivate); + + // Remove private tabs from all remaining closed windows. + browserState._closedWindows.forEach(win => this.filterPrivateTabs(win)); + }, + + /** + * Removes open private tabs from a given window state object. + * + * @param winState (object) + * The window state for which we remove any private tabs. + * The given object will be modified. + */ + filterPrivateTabs: function (winState) { + // Remove open private tabs. + for (let i = winState.tabs.length - 1; i >= 0 ; i--) { + let tab = winState.tabs[i]; + + if (tab.isPrivate) { + winState.tabs.splice(i, 1); + + if (winState.selected >= i) { + winState.selected--; + } + } + } + + // Note that closed private tabs are only stored for private windows. + // There is no need to call this function for private windows as the + // whole window state should just be discarded so we explicitly don't + // try to remove closed private tabs as an optimization. + } +}); diff --git a/browser/components/sessionstore/PrivacyLevel.jsm b/browser/components/sessionstore/PrivacyLevel.jsm new file mode 100644 index 000000000..135f1f959 --- /dev/null +++ b/browser/components/sessionstore/PrivacyLevel.jsm @@ -0,0 +1,64 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public +* License, v. 2.0. If a copy of the MPL was not distributed with this file, +* You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +this.EXPORTED_SYMBOLS = ["PrivacyLevel"]; + +const Cu = Components.utils; + +Cu.import("resource://gre/modules/Services.jsm"); + +const PREF = "browser.sessionstore.privacy_level"; + +// The following constants represent the different possible privacy levels that +// can be set by the user and that we need to consider when collecting text +// data, and cookies. +// +// Collect data from all sites (http and https). +const PRIVACY_NONE = 0; +// Collect data from unencrypted sites (http), only. +const PRIVACY_ENCRYPTED = 1; +// Collect no data. +const PRIVACY_FULL = 2; + +/** + * The external API as exposed by this module. + */ +var PrivacyLevel = Object.freeze({ + /** + * Returns whether the current privacy level allows saving data for the given + * |url|. + * + * @param url The URL we want to save data for. + * @return bool + */ + check: function (url) { + return PrivacyLevel.canSave({ isHttps: url.startsWith("https:") }); + }, + + /** + * Checks whether we're allowed to save data for a specific site. + * + * @param {isHttps: boolean} + * An object that must have one property: 'isHttps'. + * 'isHttps' tells whether the site us secure communication (HTTPS). + * @return {bool} Whether we can save data for the specified site. + */ + canSave: function ({isHttps}) { + let level = Services.prefs.getIntPref(PREF); + + // Never save any data when full privacy is requested. + if (level == PRIVACY_FULL) { + return false; + } + + // Don't save data for encrypted sites when requested. + if (isHttps && level == PRIVACY_ENCRYPTED) { + return false; + } + + return true; + } +}); diff --git a/browser/components/sessionstore/RecentlyClosedTabsAndWindowsMenuUtils.jsm b/browser/components/sessionstore/RecentlyClosedTabsAndWindowsMenuUtils.jsm new file mode 100644 index 000000000..ac5731160 --- /dev/null +++ b/browser/components/sessionstore/RecentlyClosedTabsAndWindowsMenuUtils.jsm @@ -0,0 +1,214 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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.EXPORTED_SYMBOLS = ["RecentlyClosedTabsAndWindowsMenuUtils"]; + +const kNSXUL = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; + +var Ci = Components.interfaces; +var Cc = Components.classes; +var Cr = Components.results; +var Cu = Components.utils; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/PlacesUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "PluralForm", + "resource://gre/modules/PluralForm.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "SessionStore", + "resource:///modules/sessionstore/SessionStore.jsm"); + +var navigatorBundle = Services.strings.createBundle("chrome://browser/locale/browser.properties"); + +this.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. + * @param aRestoreAllLabel (defaults to "menuRestoreAllTabs.label") + * Which localizable string to use for the 'restore all tabs' item. + * @returns A document fragment with UI items for each recently closed tab. + */ + getTabsFragment: function(aWindow, aTagName, aPrefixRestoreAll=false, + aRestoreAllLabel="menuRestoreAllTabs.label") { + let doc = aWindow.document; + let fragment = doc.createDocumentFragment(); + if (SessionStore.getClosedTabCount(aWindow) != 0) { + let closedTabs = SessionStore.getClosedTabData(aWindow, false); + for (let i = 0; i < closedTabs.length; i++) { + createEntry(aTagName, false, i, closedTabs[i], doc, + closedTabs[i].title, fragment); + } + + createRestoreAllEntry(doc, fragment, aPrefixRestoreAll, false, + aRestoreAllLabel, 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. + * @param aRestoreAllLabel (defaults to "menuRestoreAllWindows.label") + * Which localizable string to use for the 'restore all windows' item. + * @returns A document fragment with UI items for each recently closed window. + */ + getWindowsFragment: function(aWindow, aTagName, aPrefixRestoreAll=false, + aRestoreAllLabel="menuRestoreAllWindows.label") { + let closedWindowData = SessionStore.getClosedWindowData(false); + let doc = aWindow.document; + let fragment = doc.createDocumentFragment(); + if (closedWindowData.length != 0) { + let menuLabelString = navigatorBundle.GetStringFromName("menuUndoCloseWindowLabel"); + let menuLabelStringSingleTab = + navigatorBundle.GetStringFromName("menuUndoCloseWindowSingleTabLabel"); + + for (let i = 0; i < closedWindowData.length; i++) { + let undoItem = closedWindowData[i]; + let otherTabsCount = undoItem.tabs.length - 1; + let label = (otherTabsCount == 0) ? menuLabelStringSingleTab + : PluralForm.get(otherTabsCount, menuLabelString); + let menuLabel = label.replace("#1", undoItem.title) + .replace("#2", otherTabsCount); + let selectedTab = undoItem.tabs[undoItem.selected - 1]; + + createEntry(aTagName, true, i, selectedTab, doc, menuLabel, + fragment); + } + + createRestoreAllEntry(doc, fragment, aPrefixRestoreAll, true, + aRestoreAllLabel, 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: function(aEvent) { + if (aEvent.button != 1) + return; + + aEvent.view.undoCloseTab(aEvent.originalTarget.getAttribute("value")); + aEvent.view.gBrowser.moveTabToEnd(); + }, +}; + +function setImage(aItem, aElement) { + let iconURL = aItem.image; + // don't initiate a connection just to fetch a favicon (see bug 467828) + if (/^https?:/.test(iconURL)) + iconURL = "moz-anno:favicon:" + iconURL; + + aElement.setAttribute("image", iconURL); +} + +/** + * 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.createElementNS(kNSXUL, aTagName); + + element.setAttribute("label", aMenuLabel); + if (aClosedTab.image) { + setImage(aClosedTab, element); + } + 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); + } + + if (!aIsWindowsFragment) { + element.addEventListener("click", RecentlyClosedTabsAndWindowsMenuUtils._undoCloseMiddleClick, false); + } + 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.createElementNS(kNSXUL, aTagName); + restoreAllElements.classList.add("restoreallitem"); + restoreAllElements.setAttribute("label", navigatorBundle.GetStringFromName(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.createElementNS(kNSXUL, "menuseparator")); + aFragment.appendChild(restoreAllElements); + } +} \ No newline at end of file diff --git a/browser/components/sessionstore/RunState.jsm b/browser/components/sessionstore/RunState.jsm new file mode 100644 index 000000000..3cdf47718 --- /dev/null +++ b/browser/components/sessionstore/RunState.jsm @@ -0,0 +1,96 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +this.EXPORTED_SYMBOLS = ["RunState"]; + +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. + */ +this.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.jsm b/browser/components/sessionstore/SessionCookies.jsm new file mode 100644 index 000000000..b99ab927b --- /dev/null +++ b/browser/components/sessionstore/SessionCookies.jsm @@ -0,0 +1,476 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +this.EXPORTED_SYMBOLS = ["SessionCookies"]; + +const Cu = Components.utils; +const Ci = Components.interfaces; + +Cu.import("resource://gre/modules/Services.jsm", this); +Cu.import("resource://gre/modules/XPCOMUtils.jsm", this); + +XPCOMUtils.defineLazyModuleGetter(this, "Utils", + "resource://gre/modules/sessionstore/Utils.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "PrivacyLevel", + "resource:///modules/sessionstore/PrivacyLevel.jsm"); + +// MAX_EXPIRY should be 2^63-1, but JavaScript can't handle that precision. +const MAX_EXPIRY = Math.pow(2, 62); + +/** + * The external API implemented by the SessionCookies module. + */ +this.SessionCookies = Object.freeze({ + update: function (windows) { + SessionCookiesInternal.update(windows); + }, + + getHostsForWindow: function (window, checkPrivacy = false) { + return SessionCookiesInternal.getHostsForWindow(window, checkPrivacy); + }, + + restore(cookies) { + SessionCookiesInternal.restore(cookies); + } +}); + +/** + * The internal API. + */ +var SessionCookiesInternal = { + /** + * Stores whether we're initialized, yet. + */ + _initialized: false, + + /** + * Retrieve the list of all hosts contained in the given windows' session + * history entries (per window) and collect the associated cookies for those + * hosts, if any. The given state object is being modified. + * + * @param windows + * Array of window state objects. + * [{ tabs: [...], cookies: [...] }, ...] + */ + update: function (windows) { + this._ensureInitialized(); + + for (let window of windows) { + let cookies = []; + + // Collect all hosts for the current window. + let hosts = this.getHostsForWindow(window, true); + + for (let host of Object.keys(hosts)) { + let isPinned = hosts[host]; + + for (let cookie of CookieStore.getCookiesForHost(host)) { + // _getCookiesForHost() will only return hosts with the right privacy + // rules, so there is no need to do anything special with this call + // to PrivacyLevel.canSave(). + if (PrivacyLevel.canSave({isHttps: cookie.secure, isPinned: isPinned})) { + cookies.push(cookie); + } + } + } + + // Don't include/keep empty cookie sections. + if (cookies.length) { + window.cookies = cookies; + } else if ("cookies" in window) { + delete window.cookies; + } + } + }, + + /** + * Returns a map of all hosts for a given window that we might want to + * collect cookies for. + * + * @param window + * A window state object containing tabs with history entries. + * @param checkPrivacy (bool) + * Whether to check the privacy level for each host. + * @return {object} A map of hosts for a given window state object. The keys + * will be hosts, the values are boolean and determine + * whether we will use the deferred privacy level when + * checking how much data to save on quitting. + */ + getHostsForWindow: function (window, checkPrivacy = false) { + let hosts = {}; + + for (let tab of window.tabs) { + for (let entry of tab.entries) { + this._extractHostsFromEntry(entry, hosts, checkPrivacy, tab.pinned); + } + } + + return hosts; + }, + + /** + * Restores a given list of session cookies. + */ + restore(cookies) { + + for (let cookie of cookies) { + let expiry = "expiry" in cookie ? cookie.expiry : MAX_EXPIRY; + let cookieObj = { + host: cookie.host, + path: cookie.path || "", + name: cookie.name || "" + }; + if (!Services.cookies.cookieExists(cookieObj, cookie.originAttributes || {})) { + Services.cookies.add(cookie.host, cookie.path || "", cookie.name || "", + cookie.value, !!cookie.secure, !!cookie.httponly, + /* isSession = */ true, expiry, cookie.originAttributes || {}); + } + } + }, + + /** + * Handles observers notifications that are sent whenever cookies are added, + * changed, or removed. Ensures that the storage is updated accordingly. + */ + observe: function (subject, topic, data) { + switch (data) { + case "added": + case "changed": + this._updateCookie(subject); + break; + case "deleted": + this._removeCookie(subject); + break; + case "cleared": + CookieStore.clear(); + break; + case "batch-deleted": + this._removeCookies(subject); + break; + case "reload": + CookieStore.clear(); + this._reloadCookies(); + break; + default: + throw new Error("Unhandled 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: function () { + if (!this._initialized) { + this._reloadCookies(); + this._initialized = true; + Services.obs.addObserver(this, "cookie-changed", false); + } + }, + + /** + * Fill a given map with hosts found in the given entry's session history and + * any child entries. + * + * @param entry + * the history entry, serialized + * @param hosts + * the hash that will be used to store hosts eg, { hostname: true } + * @param checkPrivacy + * should we check the privacy level for https + * @param isPinned + * is the entry we're evaluating for a pinned tab; used only if + * checkPrivacy + */ + _extractHostsFromEntry: function (entry, hosts, checkPrivacy, isPinned) { + let host = entry._host; + let scheme = entry._scheme; + + // If host & scheme aren't defined, then we are likely here in the startup + // process via _splitCookiesFromWindow. In that case, we'll turn entry.url + // into an nsIURI and get host/scheme from that. This will throw for about: + // urls in which case we don't need to do anything. + if (!host && !scheme) { + try { + let uri = Utils.makeURI(entry.url); + host = uri.host; + scheme = uri.scheme; + this._extractHostsFromHostScheme(host, scheme, hosts, checkPrivacy, isPinned); + } + catch (ex) { } + } + + if (entry.children) { + for (let child of entry.children) { + this._extractHostsFromEntry(child, hosts, checkPrivacy, isPinned); + } + } + }, + + /** + * Add a given host to a given map of hosts if the privacy level allows + * saving cookie data for it. + * + * @param host + * the host of a uri (usually via nsIURI.host) + * @param scheme + * the scheme of a uri (usually via nsIURI.scheme) + * @param hosts + * the hash that will be used to store hosts eg, { hostname: true } + * @param checkPrivacy + * should we check the privacy level for https + * @param isPinned + * is the entry we're evaluating for a pinned tab; used only if + * checkPrivacy + */ + _extractHostsFromHostScheme: + function (host, scheme, hosts, checkPrivacy, isPinned) { + // host and scheme may not be set (for about: urls for example), in which + // case testing scheme will be sufficient. + if (/https?/.test(scheme) && !hosts[host] && + (!checkPrivacy || + PrivacyLevel.canSave({isHttps: scheme == "https", isPinned: isPinned}))) { + // By setting this to true or false, we can determine when looking at + // the host in update() if we should check for privacy. + hosts[host] = isPinned; + } else if (scheme == "file") { + hosts[host] = true; + } + }, + + /** + * Updates or adds a given cookie to the store. + */ + _updateCookie: function (cookie) { + cookie.QueryInterface(Ci.nsICookie2); + + if (cookie.isSession) { + CookieStore.set(cookie); + } else { + CookieStore.delete(cookie); + } + }, + + /** + * Removes a given cookie from the store. + */ + _removeCookie: function (cookie) { + cookie.QueryInterface(Ci.nsICookie2); + + if (cookie.isSession) { + CookieStore.delete(cookie); + } + }, + + /** + * Removes a given list of cookies from the store. + */ + _removeCookies: function (cookies) { + for (let i = 0; i < cookies.length; i++) { + this._removeCookie(cookies.queryElementAt(i, Ci.nsICookie2)); + } + }, + + /** + * Iterates all cookies in the cookies service and puts them into the store + * if they're session cookies. + */ + _reloadCookies: function () { + let iter = Services.cookies.enumerator; + while (iter.hasMoreElements()) { + this._updateCookie(iter.getNext()); + } + } +}; + +/** + * Generates all possible subdomains for a given host and prepends a leading + * dot to all variants. + * + * See http://tools.ietf.org/html/rfc6265#section-5.1.3 + * http://en.wikipedia.org/wiki/HTTP_cookie#Domain_and_Path + * + * All cookies belonging to a web page will be internally represented by a + * nsICookie object. nsICookie.host will be the request host if no domain + * parameter was given when setting the cookie. If a specific domain was given + * then nsICookie.host will contain that specific domain and prepend a leading + * dot to it. + * + * We thus generate all possible subdomains for a given domain and prepend a + * leading dot to them as that is the value that was used as the map key when + * the cookie was set. + */ +function* getPossibleSubdomainVariants(host) { + // Try given domain with a leading dot (.www.example.com). + yield "." + host; + + // Stop if there are only two parts left (e.g. example.com was given). + let parts = host.split("."); + if (parts.length < 3) { + return; + } + + // Remove the first subdomain (www.example.com -> example.com). + let rest = parts.slice(1).join("."); + + // Try possible parent subdomains. + yield* getPossibleSubdomainVariants(rest); +} + +/** + * The internal cookie storage that keeps track of every active session cookie. + * These are stored using maps per host, path, and cookie name. + */ +var CookieStore = { + /** + * The internal structure holding all known cookies. + * + * Host => + * Path => + * Name => {path: "/", name: "sessionid", secure: true} + * + * Maps are used for storage but the data structure is equivalent to this: + * + * this._hosts = { + * "www.mozilla.org": { + * "/": { + * "username": {name: "username", value: "my_name_is", etc...}, + * "sessionid": {name: "sessionid", value: "1fdb3a", etc...} + * } + * }, + * "tbpl.mozilla.org": { + * "/path": { + * "cookiename": {name: "cookiename", value: "value", etc...} + * } + * }, + * ".example.com": { + * "/path": { + * "cookiename": {name: "cookiename", value: "value", etc...} + * } + * } + * }; + */ + _hosts: new Map(), + + /** + * Returns the list of stored session cookies for a given host. + * + * @param host + * A string containing the host name we want to get cookies for. + */ + getCookiesForHost: function (host) { + let cookies = []; + + let appendCookiesForHost = host => { + if (!this._hosts.has(host)) { + return; + } + + for (let pathToNamesMap of this._hosts.get(host).values()) { + for (let nameToCookiesMap of pathToNamesMap.values()) { + cookies.push(...nameToCookiesMap.values()); + } + } + } + + // Try to find cookies for the given host, e.g. . + // The full hostname will be in the map if the Set-Cookie header did not + // have a domain= attribute, i.e. the cookie will only be stored for the + // request domain. Also, try to find cookies for subdomains, e.g. + // <.example.com>. We will find those variants with a leading dot in the + // map if the Set-Cookie header had a domain= attribute, i.e. the cookie + // will be stored for a parent domain and we send it for any subdomain. + for (let variant of [host, ...getPossibleSubdomainVariants(host)]) { + appendCookiesForHost(variant); + } + + return cookies; + }, + + /** + * Stores a given cookie. + * + * @param cookie + * The nsICookie2 object to add to the storage. + */ + set: function (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; + } + + this._ensureMap(cookie).set(cookie.name, jscookie); + }, + + /** + * Removes a given cookie. + * + * @param cookie + * The nsICookie2 object to be removed from storage. + */ + delete: function (cookie) { + this._ensureMap(cookie).delete(cookie.name); + }, + + /** + * Removes all cookies. + */ + clear: function () { + this._hosts.clear(); + }, + + /** + * Creates all maps necessary to store a given cookie. + * + * @param cookie + * The nsICookie2 object to create maps for. + * + * @return The newly created Map instance mapping cookie names to + * internal jscookies, in the given path of the given host. + */ + _ensureMap: function (cookie) { + if (!this._hosts.has(cookie.host)) { + this._hosts.set(cookie.host, new Map()); + } + + let originAttributesMap = this._hosts.get(cookie.host); + // If cookie.originAttributes is null, originAttributes will be an empty string. + let originAttributes = ChromeUtils.originAttributesToSuffix(cookie.originAttributes); + if (!originAttributesMap.has(originAttributes)) { + originAttributesMap.set(originAttributes, new Map()); + } + + let pathToNamesMap = originAttributesMap.get(originAttributes); + + if (!pathToNamesMap.has(cookie.path)) { + pathToNamesMap.set(cookie.path, new Map()); + } + + return pathToNamesMap.get(cookie.path); + } +}; diff --git a/browser/components/sessionstore/SessionFile.jsm b/browser/components/sessionstore/SessionFile.jsm new file mode 100644 index 000000000..80c4e7790 --- /dev/null +++ b/browser/components/sessionstore/SessionFile.jsm @@ -0,0 +1,399 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +this.EXPORTED_SYMBOLS = ["SessionFile"]; + +/** + * 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 implicitly depends on one of two things: + * 1. either the asynchronous file I/O system enqueues its requests + * and never attempts to simultaneously execute two I/O requests on + * the files used by this module from two distinct threads; or + * 2. the clients of this API are well-behaved and do not place + * concurrent requests to the files used by this module. + * + * 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. + * + * This implementation uses OS.File, which guarantees property 1. + */ + +const Cu = Components.utils; +const Cc = Components.classes; +const Ci = Components.interfaces; +const Cr = Components.results; + +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/osfile.jsm"); +Cu.import("resource://gre/modules/Promise.jsm"); +Cu.import("resource://gre/modules/AsyncShutdown.jsm"); +Cu.import("resource://gre/modules/Preferences.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "console", + "resource://gre/modules/Console.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "PromiseUtils", + "resource://gre/modules/PromiseUtils.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "RunState", + "resource:///modules/sessionstore/RunState.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "TelemetryStopwatch", + "resource://gre/modules/TelemetryStopwatch.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Task", + "resource://gre/modules/Task.jsm"); +XPCOMUtils.defineLazyServiceGetter(this, "Telemetry", + "@mozilla.org/base/telemetry;1", "nsITelemetry"); +XPCOMUtils.defineLazyServiceGetter(this, "sessionStartup", + "@mozilla.org/browser/sessionstartup;1", "nsISessionStartup"); +XPCOMUtils.defineLazyModuleGetter(this, "SessionWorker", + "resource:///modules/sessionstore/SessionWorker.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "SessionStore", + "resource:///modules/sessionstore/SessionStore.jsm"); + +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"; + +this.SessionFile = { + /** + * Read the contents of the session file, asynchronously. + */ + read: function () { + return SessionFileInternal.read(); + }, + /** + * Write the contents of the session file, asynchronously. + */ + write: function (aData) { + return SessionFileInternal.write(aData); + }, + /** + * Wipe the contents of the session file, asynchronously. + */ + wipe: function () { + 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); + +var Path = OS.Path; +var profileDir = OS.Constants.Path.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: Path.join(profileDir, "sessionstore.js"), + + // The path at which we store the previous version of `clean`. Updated + // whenever we successfully load from `clean`. + cleanBackup: Path.join(profileDir, "sessionstore-backups", "previous.js"), + + // The directory containing all sessionstore backups. + backups: Path.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: Path.join(profileDir, "sessionstore-backups", "recovery.js"), + + // 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: Path.join(profileDir, "sessionstore-backups", "recovery.bak"), + + // 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: Path.join(profileDir, "sessionstore-backups", "upgrade.js-"), + + // 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, + + // Resolved once initialization is complete. + // The promise never rejects. + _deferredInitialized: PromiseUtils.defer(), + + // `true` once we have started initialization, i.e. once something + // has been scheduled that will eventually resolve `_deferredInitialized`. + _initializationStarted: 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; + } + }, + + // Find the correct session file, read it and setup the worker. + read: Task.async(function* () { + this._initializationStarted = true; + + let result; + let noFilesFound = true; + // 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 = this.Paths[key]; + let startMs = Date.now(); + + let source = yield OS.File.read(path, { encoding: "utf-8" }); + let parsed = JSON.parse(source); + + if (!SessionStore.isFormatVersionCompatible(parsed.version || ["sessionrestore", 0] /*fallback for old versions*/)) { + // Skip sessionstore files that we don't understand. + Cu.reportError("Cannot extract data from Session Restore file " + path + ". Wrong format/version: " + JSON.stringify(parsed.version) + "."); + continue; + } + result = { + origin: key, + source: source, + parsed: parsed + }; + Telemetry.getHistogramById("FX_SESSION_RESTORE_CORRUPT_FILE"). + add(false); + Telemetry.getHistogramById("FX_SESSION_RESTORE_READ_FILE_MS"). + add(Date.now() - startMs); + break; + } catch (ex if ex instanceof OS.File.Error && ex.becauseNoSuchFile) { + exists = false; + } catch (ex if ex instanceof OS.File.Error) { + // 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, ex.stack); + corrupted = true; + } catch (ex 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; + Telemetry.getHistogramById("FX_SESSION_RESTORE_CORRUPT_FILE"). + add(corrupted); + } + } + } + + // All files are corrupted if files found but none could deliver a result. + let allCorrupt = !noFilesFound && !result; + 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 + }; + } + + result.noFilesFound = noFilesFound; + + // Initialize the worker (in the background) to let it handle backups and also + // as a workaround for bug 964531. + let promiseInitialized = SessionWorker.post("init", [result.origin, this.Paths, { + maxUpgradeBackups: Preferences.get(PREF_MAX_UPGRADE_BACKUPS, 3), + maxSerializeBack: Preferences.get(PREF_MAX_SERIALIZE_BACK, 10), + maxSerializeForward: Preferences.get(PREF_MAX_SERIALIZE_FWD, -1) + }]); + + promiseInitialized.catch(err => { + // Ensure that we report errors but that they do not stop us. + Promise.reject(err); + }).then(() => this._deferredInitialized.resolve()); + + return result; + }), + + // Post a message to the worker, making sure that it has been initialized + // first. + _postToWorker: Task.async(function*(...args) { + if (!this._initializationStarted) { + // Initializing the worker is somewhat complex, as proper handling of + // backups requires us to first read and check the session. Consequently, + // the only way to initialize the worker is to first call `this.read()`. + + // The call to `this.read()` causes background initialization of the worker. + // Initialization will be complete once `this._deferredInitialized.promise` + // resolves. + this.read(); + } + yield this._deferredInitialized.promise; + return SessionWorker.post(...args) + }), + + write: function (aData) { + if (RunState.isClosed) { + return Promise.reject(new Error("SessionFile is closed")); + } + + let isFinalWrite = false; + if (RunState.isClosing) { + // If shutdown has started, we will want to stop receiving + // write instructions. + isFinalWrite = true; + RunState.setClosed(); + } + + let performShutdownCleanup = isFinalWrite && + !sessionStartup.isAutomaticRestoreEnabled(); + + this._attempts++; + let options = {isFinalWrite, performShutdownCleanup}; + let promise = this._postToWorker("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. + AsyncShutdown.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. + AsyncShutdown.profileBeforeChange.removeBlocker(promise); + + if (isFinalWrite) { + Services.obs.notifyObservers(null, "sessionstore-final-state-write-complete", ""); + } + }); + }, + + wipe: function () { + return this._postToWorker("wipe"); + }, + + _recordTelemetry: function(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 = Telemetry.getHistogramById(id); + for (let sample of samples) { + histogram.add(sample); + } + } + } +}; diff --git a/browser/components/sessionstore/SessionHistory.jsm b/browser/components/sessionstore/SessionHistory.jsm new file mode 100644 index 000000000..aa9c10379 --- /dev/null +++ b/browser/components/sessionstore/SessionHistory.jsm @@ -0,0 +1,428 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public +* License, v. 2.0. If a copy of the MPL was not distributed with this file, +* You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +this.EXPORTED_SYMBOLS = ["SessionHistory"]; + +const Cu = Components.utils; +const Cc = Components.classes; +const Ci = Components.interfaces; + +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "Utils", + "resource://gre/modules/sessionstore/Utils.jsm"); + +function debug(msg) { + Services.console.logStringMessage("SessionHistory: " + msg); +} + +/** + * The external API exported by this module. + */ +this.SessionHistory = Object.freeze({ + isEmpty: function (docShell) { + return SessionHistoryInternal.isEmpty(docShell); + }, + + collect: function (docShell) { + return SessionHistoryInternal.collect(docShell); + }, + + restore: function (docShell, tabData) { + SessionHistoryInternal.restore(docShell, tabData); + } +}); + +/** + * The internal API for the SessionHistory module. + */ +var SessionHistoryInternal = { + /** + * Returns whether the given docShell's session history is empty. + * + * @param docShell + * The docShell that owns the session history. + */ + isEmpty: function (docShell) { + let webNavigation = docShell.QueryInterface(Ci.nsIWebNavigation); + let history = webNavigation.sessionHistory; + if (!webNavigation.currentURI) { + return true; + } + let uri = webNavigation.currentURI.spec; + return uri == "about:blank" && history.count == 0; + }, + + /** + * Collects session history data for a given docShell. + * + * @param docShell + * The docShell that owns the session history. + */ + collect: function (docShell) { + let loadContext = docShell.QueryInterface(Ci.nsILoadContext); + let webNavigation = docShell.QueryInterface(Ci.nsIWebNavigation); + let history = webNavigation.sessionHistory.QueryInterface(Ci.nsISHistoryInternal); + + let data = {entries: [], userContextId: loadContext.originAttributes.userContextId }; + + if (history && history.count > 0) { + // Loop over the transaction linked list directly so we can get the + // persist property for each transaction. + for (let txn = history.rootTransaction; txn; txn = txn.next) { + let entry = this.serializeEntry(txn.sHEntry); + entry.persist = txn.persist; + data.entries.push(entry); + } + + // Ensure the index isn't out of bounds if an exception was thrown above. + data.index = Math.min(history.index + 1, data.entries.length); + } + + // If either the session history isn't available yet or doesn't have any + // valid entries, make sure we at least include the current page. + if (data.entries.length == 0) { + let uri = webNavigation.currentURI.spec; + let body = webNavigation.document.body; + // We landed here because the history is inaccessible or there are no + // history entries. In that case we should at least record the docShell's + // current URL as a single history entry. If the URL is not about:blank + // or it's a blank tab that was modified (like a custom newtab page), + // record it. For about:blank we explicitly want an empty array without + // an 'index' property to denote that there are no history entries. + if (uri != "about:blank" || (body && body.hasChildNodes())) { + data.entries.push({ url: uri }); + data.index = 1; + } + } + + return data; + }, + + /** + * Get an object that is a serialized representation of a History entry. + * + * @param shEntry + * nsISHEntry instance + * @return object + */ + serializeEntry: function (shEntry) { + let entry = { url: shEntry.URI.spec }; + + // Save some bytes and don't include the title property + // if that's identical to the current entry's URL. + if (shEntry.title && shEntry.title != entry.url) { + entry.title = shEntry.title; + } + if (shEntry.isSubFrame) { + entry.subframe = true; + } + + entry.charset = shEntry.URI.originCharset; + + let cacheKey = shEntry.cacheKey; + if (cacheKey && cacheKey instanceof Ci.nsISupportsPRUint32 && + cacheKey.data != 0) { + // XXXbz would be better to have cache keys implement + // nsISerializable or something. + entry.cacheKey = cacheKey.data; + } + entry.ID = shEntry.ID; + entry.docshellID = shEntry.docshellID; + + // We will include the property only if it's truthy to save a couple of + // bytes when the resulting object is stringified and saved to disk. + if (shEntry.referrerURI) { + entry.referrer = shEntry.referrerURI.spec; + entry.referrerPolicy = shEntry.referrerPolicy; + } + + if (shEntry.originalURI) { + entry.originalURI = shEntry.originalURI.spec; + } + + if (shEntry.loadReplace) { + entry.loadReplace = shEntry.loadReplace; + } + + if (shEntry.srcdocData) + entry.srcdocData = shEntry.srcdocData; + + if (shEntry.isSrcdocEntry) + entry.isSrcdocEntry = shEntry.isSrcdocEntry; + + if (shEntry.baseURI) + entry.baseURI = shEntry.baseURI.spec; + + if (shEntry.contentType) + entry.contentType = shEntry.contentType; + + if (shEntry.scrollRestorationIsManual) { + entry.scrollRestorationIsManual = true; + } else { + let x = {}, y = {}; + shEntry.getScrollPosition(x, y); + if (x.value != 0 || y.value != 0) + entry.scroll = x.value + "," + y.value; + } + + // Collect triggeringPrincipal data for the current history entry. + // Please note that before Bug 1297338 there was no concept of a + // principalToInherit. To remain backward/forward compatible we + // serialize the principalToInherit as triggeringPrincipal_b64. + // Once principalToInherit is well established (within FF55) + // we can update this code, remove triggeringPrincipal_b64 and + // just keep triggeringPrincipal_base64 as well as + // principalToInherit_base64; see Bug 1301666. + if (shEntry.principalToInherit) { + try { + let principalToInherit = Utils.serializePrincipal(shEntry.principalToInherit); + if (principalToInherit) { + entry.triggeringPrincipal_b64 = principalToInherit; + entry.principalToInherit_base64 = principalToInherit; + } + } catch (e) { + debug(e); + } + } + + if (shEntry.triggeringPrincipal) { + try { + let triggeringPrincipal = Utils.serializePrincipal(shEntry.triggeringPrincipal); + if (triggeringPrincipal) { + entry.triggeringPrincipal_base64 = triggeringPrincipal; + } + } catch (e) { + debug(e); + } + } + + entry.docIdentifier = shEntry.BFCacheEntry.ID; + + if (shEntry.stateData != null) { + entry.structuredCloneState = shEntry.stateData.getDataAsBase64(); + entry.structuredCloneVersion = shEntry.stateData.formatVersion; + } + + if (!(shEntry instanceof Ci.nsISHContainer)) { + return entry; + } + + if (shEntry.childCount > 0 && !shEntry.hasDynamicallyAddedChild()) { + let children = []; + for (let i = 0; i < shEntry.childCount; i++) { + let child = shEntry.GetChildAt(i); + + if (child) { + // Don't try to restore framesets containing wyciwyg URLs. + // (cf. bug 424689 and bug 450595) + if (child.URI.schemeIs("wyciwyg")) { + children.length = 0; + break; + } + + children.push(this.serializeEntry(child)); + } + } + + if (children.length) { + entry.children = children; + } + } + + return entry; + }, + + /** + * Restores session history data for a given docShell. + * + * @param docShell + * The docShell that owns the session history. + * @param tabData + * The tabdata including all history entries. + */ + restore: function (docShell, tabData) { + let webNavigation = docShell.QueryInterface(Ci.nsIWebNavigation); + let history = webNavigation.sessionHistory; + if (history.count > 0) { + history.PurgeHistory(history.count); + } + history.QueryInterface(Ci.nsISHistoryInternal); + + let idMap = { used: {} }; + let docIdentMap = {}; + for (let i = 0; i < tabData.entries.length; i++) { + let entry = tabData.entries[i]; + //XXXzpao Wallpaper patch for bug 514751 + if (!entry.url) + continue; + let persist = "persist" in entry ? entry.persist : true; + history.addEntry(this.deserializeEntry(entry, idMap, docIdentMap), persist); + } + + // Select the right history entry. + let index = tabData.index - 1; + if (index < history.count && history.index != index) { + history.getEntryAtIndex(index, true); + } + }, + + /** + * Expands serialized history data into a session-history-entry instance. + * + * @param entry + * Object containing serialized history data for a URL + * @param idMap + * Hash for ensuring unique frame IDs + * @param docIdentMap + * Hash to ensure reuse of BFCache entries + * @returns nsISHEntry + */ + deserializeEntry: function (entry, idMap, docIdentMap) { + + var shEntry = Cc["@mozilla.org/browser/session-history-entry;1"]. + createInstance(Ci.nsISHEntry); + + shEntry.setURI(Utils.makeURI(entry.url, entry.charset)); + shEntry.setTitle(entry.title || entry.url); + if (entry.subframe) + shEntry.setIsSubFrame(entry.subframe || false); + shEntry.loadType = Ci.nsIDocShellLoadInfo.loadHistory; + if (entry.contentType) + shEntry.contentType = entry.contentType; + if (entry.referrer) { + shEntry.referrerURI = Utils.makeURI(entry.referrer); + shEntry.referrerPolicy = entry.referrerPolicy; + } + if (entry.originalURI) { + shEntry.originalURI = Utils.makeURI(entry.originalURI); + } + if (entry.loadReplace) { + shEntry.loadReplace = entry.loadReplace; + } + if (entry.isSrcdocEntry) + shEntry.srcdocData = entry.srcdocData; + if (entry.baseURI) + shEntry.baseURI = Utils.makeURI(entry.baseURI); + + if (entry.cacheKey) { + var cacheKey = Cc["@mozilla.org/supports-PRUint32;1"]. + createInstance(Ci.nsISupportsPRUint32); + cacheKey.data = entry.cacheKey; + shEntry.cacheKey = cacheKey; + } + + if (entry.ID) { + // get a new unique ID for this frame (since the one from the last + // start might already be in use) + var id = idMap[entry.ID] || 0; + if (!id) { + for (id = Date.now(); id in idMap.used; id++); + idMap[entry.ID] = id; + idMap.used[id] = true; + } + shEntry.ID = id; + } + + if (entry.docshellID) + shEntry.docshellID = entry.docshellID; + + if (entry.structuredCloneState && entry.structuredCloneVersion) { + shEntry.stateData = + Cc["@mozilla.org/docshell/structured-clone-container;1"]. + createInstance(Ci.nsIStructuredCloneContainer); + + shEntry.stateData.initFromBase64(entry.structuredCloneState, + entry.structuredCloneVersion); + } + + if (entry.scrollRestorationIsManual) { + shEntry.scrollRestorationIsManual = true; + } else if (entry.scroll) { + var scrollPos = (entry.scroll || "0,0").split(","); + scrollPos = [parseInt(scrollPos[0]) || 0, parseInt(scrollPos[1]) || 0]; + shEntry.setScrollPosition(scrollPos[0], scrollPos[1]); + } + + let childDocIdents = {}; + if (entry.docIdentifier) { + // If we have a serialized document identifier, try to find an SHEntry + // which matches that doc identifier and adopt that SHEntry's + // BFCacheEntry. If we don't find a match, insert shEntry as the match + // for the document identifier. + let matchingEntry = docIdentMap[entry.docIdentifier]; + if (!matchingEntry) { + matchingEntry = {shEntry: shEntry, childDocIdents: childDocIdents}; + docIdentMap[entry.docIdentifier] = matchingEntry; + } + else { + shEntry.adoptBFCacheEntry(matchingEntry.shEntry); + childDocIdents = matchingEntry.childDocIdents; + } + } + + // The field entry.owner_b64 got renamed to entry.triggeringPricipal_b64 in + // Bug 1286472. To remain backward compatible we still have to support that + // field for a few cycles before we can remove it within Bug 1289785. + if (entry.owner_b64) { + entry.triggeringPricipal_b64 = entry.owner_b64; + delete entry.owner_b64; + } + + // Before introducing the concept of principalToInherit we only had + // a triggeringPrincipal within every entry which basically is the + // equivalent of the new principalToInherit. To avoid compatibility + // issues, we first check if the entry has entries for + // triggeringPrincipal_base64 and principalToInherit_base64. If not + // we fall back to using the principalToInherit (which is stored + // as triggeringPrincipal_b64) as the triggeringPrincipal and + // the principalToInherit. + // FF55 will remove the triggeringPrincipal_b64, see Bug 1301666. + if (entry.triggeringPrincipal_base64 || entry.principalToInherit_base64) { + if (entry.triggeringPrincipal_base64) { + shEntry.triggeringPrincipal = + Utils.deserializePrincipal(entry.triggeringPrincipal_base64); + } + if (entry.principalToInherit_base64) { + shEntry.principalToInherit = + Utils.deserializePrincipal(entry.principalToInherit_base64); + } + } else if (entry.triggeringPrincipal_b64) { + shEntry.triggeringPrincipal = Utils.deserializePrincipal(entry.triggeringPrincipal_b64); + shEntry.principalToInherit = shEntry.triggeringPrincipal; + } + + if (entry.children && shEntry instanceof Ci.nsISHContainer) { + for (var i = 0; i < entry.children.length; i++) { + //XXXzpao Wallpaper patch for bug 514751 + if (!entry.children[i].url) + continue; + + // We're getting sessionrestore.js files with a cycle in the + // doc-identifier graph, likely due to bug 698656. (That is, we have + // an entry where doc identifier A is an ancestor of doc identifier B, + // and another entry where doc identifier B is an ancestor of A.) + // + // If we were to respect these doc identifiers, we'd create a cycle in + // the SHEntries themselves, which causes the docshell to loop forever + // when it looks for the root SHEntry. + // + // So as a hack to fix this, we restrict the scope of a doc identifier + // to be a node's siblings and cousins, and pass childDocIdents, not + // aDocIdents, to _deserializeHistoryEntry. That is, we say that two + // SHEntries with the same doc identifier have the same document iff + // they have the same parent or their parents have the same document. + + shEntry.AddChild(this.deserializeEntry(entry.children[i], idMap, + childDocIdents), i); + } + } + + return shEntry; + }, + +}; diff --git a/browser/components/sessionstore/SessionMigration.jsm b/browser/components/sessionstore/SessionMigration.jsm new file mode 100644 index 000000000..ff339eba9 --- /dev/null +++ b/browser/components/sessionstore/SessionMigration.jsm @@ -0,0 +1,100 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +this.EXPORTED_SYMBOLS = ["SessionMigration"]; + +const Cu = Components.utils; +Cu.import("resource://gre/modules/XPCOMUtils.jsm", this); +Cu.import("resource://gre/modules/Task.jsm", this); +Cu.import("resource://gre/modules/osfile.jsm", this); + +// An encoder to UTF-8. +XPCOMUtils.defineLazyGetter(this, "gEncoder", function () { + return new TextEncoder(); +}); + +// A decoder. +XPCOMUtils.defineLazyGetter(this, "gDecoder", function () { + return new TextDecoder(); +}); + +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 + * - 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: function(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 and urls for history entries + tab.entries = oldTab.entries.map(function(entry) { + return {url: entry.url, 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}; + return {windows: [{tabs: [{entries: [{url}], formdata}]}]}; + }, + /** + * Asynchronously read session restore state (JSON) from a path + */ + readState: function(aPath) { + return Task.spawn(function() { + let bytes = yield OS.File.read(aPath); + let text = gDecoder.decode(bytes); + let state = JSON.parse(text); + throw new Task.Result(state); + }); + }, + /** + * Asynchronously write session restore state as JSON to a path + */ + writeState: function(aPath, aState) { + let bytes = gEncoder.encode(JSON.stringify(aState)); + return OS.File.writeAtomic(aPath, bytes, {tmpPath: aPath + ".tmp"}); + } +} + +var SessionMigration = { + /** + * Migrate a limited set of session data from one path to another. + */ + migrate: function(aFromPath, aToPath) { + return Task.spawn(function() { + let inState = yield 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. + yield SessionMigrationInternal.writeState(aToPath, outState); + }); + } +}; diff --git a/browser/components/sessionstore/SessionSaver.jsm b/browser/components/sessionstore/SessionSaver.jsm new file mode 100644 index 000000000..d672f8877 --- /dev/null +++ b/browser/components/sessionstore/SessionSaver.jsm @@ -0,0 +1,264 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +this.EXPORTED_SYMBOLS = ["SessionSaver"]; + +const Cu = Components.utils; +const Cc = Components.classes; +const Ci = Components.interfaces; + +Cu.import("resource://gre/modules/Timer.jsm", this); +Cu.import("resource://gre/modules/Services.jsm", this); +Cu.import("resource://gre/modules/XPCOMUtils.jsm", this); +Cu.import("resource://gre/modules/TelemetryStopwatch.jsm", this); + +XPCOMUtils.defineLazyModuleGetter(this, "AppConstants", + "resource://gre/modules/AppConstants.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "console", + "resource://gre/modules/Console.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "PrivacyFilter", + "resource:///modules/sessionstore/PrivacyFilter.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "RunState", + "resource:///modules/sessionstore/RunState.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "SessionStore", + "resource:///modules/sessionstore/SessionStore.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "SessionFile", + "resource:///modules/sessionstore/SessionFile.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "PrivateBrowsingUtils", + "resource://gre/modules/PrivateBrowsingUtils.jsm"); + +// Minimal interval between two save operations (in milliseconds). +XPCOMUtils.defineLazyGetter(this, "gInterval", function () { + const PREF = "browser.sessionstore.interval"; + + // Observer that updates the cached value when the preference changes. + Services.prefs.addObserver(PREF, () => { + this.gInterval = Services.prefs.getIntPref(PREF); + + // Cancel any pending runs and call runDelayed() with + // zero to apply the newly configured interval. + SessionSaverInternal.cancel(); + SessionSaverInternal.runDelayed(0); + }, false); + + return Services.prefs.getIntPref(PREF); +}); + +// 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 stopWatchCancel = stopWatch("cancel"); +var stopWatchFinish = stopWatch("finish"); + +/** + * The external API implemented by the SessionSaver module. + */ +this.SessionSaver = Object.freeze({ + /** + * Immediately saves the current session to disk. + */ + run: function () { + 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: function () { + 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: function () { + SessionSaverInternal.updateLastSaveTime(); + }, + + /** + * Cancels all pending session saves. + */ + cancel: function () { + 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, + + /** + * 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, + + /** + * Immediately saves the current session to disk. + */ + run: function () { + 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: function (delay = 2000) { + // Bail out if there's a pending run. + if (this._timeoutID) { + return; + } + + // Interval until the next disk operation is allowed. + delay = Math.max(this._lastSaveTime + gInterval - Date.now(), delay, 0); + + // Schedule a state save. + this._timeoutID = setTimeout(() => this._saveStateAsync(), 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: function () { + this._lastSaveTime = Date.now(); + }, + + /** + * Cancels all pending session saves. + */ + cancel: function () { + clearTimeout(this._timeoutID); + this._timeoutID = null; + }, + + /** + * 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: function (forceUpdateAllWindows = false) { + // Cancel any pending timeouts. + this.cancel(); + + if (PrivateBrowsingUtils.permanentPrivateBrowsing) { + // Don't save (or even collect) anything in permanent private + // browsing mode + + this.updateLastSaveTime(); + return Promise.resolve(); + } + + stopWatchStart("COLLECT_DATA_MS", "COLLECT_DATA_LONGEST_OP_MS"); + let state = SessionStore.getCurrentState(forceUpdateAllWindows); + PrivacyFilter.filterPrivateWindowsAndTabs(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 all cookies on clean shutdown according to user preferences + if (RunState.isClosing) { + let expireCookies = Services.prefs.getIntPref("network.cookie.lifetimePolicy") == + Services.cookies.QueryInterface(Ci.nsICookieService).ACCEPT_SESSION; + let sanitizeCookies = Services.prefs.getBoolPref("privacy.sanitize.sanitizeOnShutdown") && + Services.prefs.getBoolPref("privacy.clearOnShutdown.cookies"); + let restart = Services.prefs.getBoolPref("browser.sessionstore.resume_session_once"); + // Don't clear cookies when restarting + if ((expireCookies || sanitizeCookies) && !restart) { + for (let window of state.windows) { + delete window.cookies; + } + } + } + + stopWatchFinish("COLLECT_DATA_MS", "COLLECT_DATA_LONGEST_OP_MS"); + return this._writeState(state); + }, + + /** + * 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: function () { + // Allow scheduling delayed saves again. + this._timeoutID = null; + + // Write to disk. + this._saveState(); + }, + + /** + * Write the given state object to disk. + */ + _writeState: function (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 SessionFile.write(state).then(() => { + this.updateLastSaveTime(); + notify(null, "sessionstore-state-write-complete"); + }, console.error); + }, +}; diff --git a/browser/components/sessionstore/SessionStorage.jsm b/browser/components/sessionstore/SessionStorage.jsm new file mode 100644 index 000000000..705139ebf --- /dev/null +++ b/browser/components/sessionstore/SessionStorage.jsm @@ -0,0 +1,173 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public +* License, v. 2.0. If a copy of the MPL was not distributed with this file, +* You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +this.EXPORTED_SYMBOLS = ["SessionStorage"]; + +const Cu = Components.utils; +const Ci = Components.interfaces; + +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "console", + "resource://gre/modules/Console.jsm"); + +// Returns the principal for a given |frame| contained in a given |docShell|. +function getPrincipalForFrame(docShell, frame) { + let ssm = Services.scriptSecurityManager; + let uri = frame.document.documentURIObject; + return ssm.getDocShellCodebasePrincipal(uri, docShell); +} + +this.SessionStorage = Object.freeze({ + /** + * Updates all sessionStorage "super cookies" + * @param docShell + * That tab's docshell (containing the sessionStorage) + * @param frameTree + * The docShell's FrameTree instance. + * @return Returns a nested object that will have hosts as keys and per-host + * session storage data as strings. For example: + * {"example.com": {"key": "value", "my_number": "123"}} + */ + collect: function (docShell, frameTree) { + return SessionStorageInternal.collect(docShell, frameTree); + }, + + /** + * Restores all sessionStorage "super cookies". + * @param aDocShell + * A tab's docshell (containing the sessionStorage) + * @param aStorageData + * A nested object with storage data to be restored that has hosts as + * keys and per-host session storage data as strings. For example: + * {"example.com": {"key": "value", "my_number": "123"}} + */ + restore: function (aDocShell, aStorageData) { + SessionStorageInternal.restore(aDocShell, aStorageData); + }, +}); + +var SessionStorageInternal = { + /** + * Reads all session storage data from the given docShell. + * @param docShell + * A tab's docshell (containing the sessionStorage) + * @param frameTree + * The docShell's FrameTree instance. + * @return Returns a nested object that will have hosts as keys and per-host + * session storage data as strings. For example: + * {"example.com": {"key": "value", "my_number": "123"}} + */ + collect: function (docShell, frameTree) { + let data = {}; + let visitedOrigins = new Set(); + + frameTree.forEach(frame => { + let principal = getPrincipalForFrame(docShell, frame); + if (!principal) { + return; + } + + // Get the origin of the current history entry + // and use that as a key for the per-principal storage data. + let origin = principal.origin; + if (visitedOrigins.has(origin)) { + // Don't read a host twice. + return; + } + + // Mark the current origin as visited. + visitedOrigins.add(origin); + + let originData = this._readEntry(principal, docShell); + if (Object.keys(originData).length) { + data[origin] = originData; + } + }); + + return Object.keys(data).length ? data : null; + }, + + /** + * Writes session storage data to the given tab. + * @param aDocShell + * A tab's docshell (containing the sessionStorage) + * @param aStorageData + * A nested object with storage data to be restored that has hosts as + * keys and per-host session storage data as strings. For example: + * {"example.com": {"key": "value", "my_number": "123"}} + */ + restore: function (aDocShell, aStorageData) { + for (let origin of Object.keys(aStorageData)) { + let data = aStorageData[origin]; + + let principal; + + try { + let attrs = aDocShell.getOriginAttributes(); + let originURI = Services.io.newURI(origin, null, null); + principal = Services.scriptSecurityManager.createCodebasePrincipal(originURI, attrs); + } catch (e) { + console.error(e); + continue; + } + + let storageManager = aDocShell.QueryInterface(Ci.nsIDOMStorageManager); + let window = aDocShell.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIDOMWindow); + + // There is no need to pass documentURI, it's only used to fill documentURI property of + // domstorage event, which in this case has no consumer. Prevention of events in case + // of missing documentURI will be solved in a followup bug to bug 600307. + let storage = storageManager.createStorage(window, principal, "", aDocShell.usePrivateBrowsing); + + for (let key of Object.keys(data)) { + try { + storage.setItem(key, data[key]); + } catch (e) { + // throws e.g. for URIs that can't have sessionStorage + console.error(e); + } + } + } + }, + + /** + * Reads an entry in the session storage data contained in a tab's history. + * @param aURI + * That history entry uri + * @param aDocShell + * A tab's docshell (containing the sessionStorage) + */ + _readEntry: function (aPrincipal, aDocShell) { + let hostData = {}; + let storage; + + let window = aDocShell.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIDOMWindow); + + try { + let storageManager = aDocShell.QueryInterface(Ci.nsIDOMStorageManager); + storage = storageManager.getStorage(window, aPrincipal); + storage.length; // XXX: Bug 1232955 - storage.length can throw, catch that failure + } catch (e) { + // sessionStorage might throw if it's turned off, see bug 458954 + storage = null; + } + + if (storage && storage.length) { + for (let i = 0; i < storage.length; i++) { + try { + let key = storage.key(i); + hostData[key] = storage.getItem(key); + } catch (e) { + // This currently throws for secured items (cf. bug 442048). + } + } + } + + return hostData; + } +}; diff --git a/browser/components/sessionstore/SessionStore.jsm b/browser/components/sessionstore/SessionStore.jsm new file mode 100644 index 000000000..2f44b2af3 --- /dev/null +++ b/browser/components/sessionstore/SessionStore.jsm @@ -0,0 +1,4719 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +this.EXPORTED_SYMBOLS = ["SessionStore"]; + +const Cu = Components.utils; +const Cc = Components.classes; +const Ci = Components.interfaces; +const Cr = Components.results; + +// Current version of the format used by Session Restore. +const FORMAT_VERSION = 1; + +const TAB_STATE_NEEDS_RESTORE = 1; +const TAB_STATE_RESTORING = 2; +const TAB_STATE_WILL_RESTORE = 3; + +// 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_TAB_RESTORED = "sessionstore-debug-tab-restored"; // WARNING: debug-only + +// Maximum number of tabs to restore simultaneously. Previously controlled by +// the browser.sessionstore.max_concurrent_tabs pref. +const MAX_CONCURRENT_TAB_RESTORES = 3; + +// 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 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-domain-data", + "idle-daily", +]; + +// XUL Window properties to (re)store +// Restored in restoreDimensions() +const WINDOW_ATTRIBUTES = ["width", "height", "screenX", "screenY", "sizemode"]; + +// Hideable window features to (re)store +// Restored in restoreWindowFeatures() +const WINDOW_HIDEABLE_FEATURES = [ + "menubar", "toolbar", "locationbar", "personalbar", "statusbar", "scrollbars" +]; + +// 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", + + // A crashed tab was revived by navigating to a different page. Remove its + // browser from the list of crashed browsers to stop ignoring its messages. + "SessionStore:crashedTabRevived", + + // 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:crashedTabRevived", + + // 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:crashedTabRevived", + + // 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:crashedTabRevived", + + // 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 NS_XUL = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; + +Cu.import("resource://gre/modules/PrivateBrowsingUtils.jsm", this); +Cu.import("resource://gre/modules/Promise.jsm", this); +Cu.import("resource://gre/modules/Services.jsm", this); +Cu.import("resource://gre/modules/Task.jsm", this); +Cu.import("resource://gre/modules/TelemetryStopwatch.jsm", this); +Cu.import("resource://gre/modules/TelemetryTimestamps.jsm", this); +Cu.import("resource://gre/modules/Timer.jsm", this); +Cu.import("resource://gre/modules/XPCOMUtils.jsm", this); +Cu.import("resource://gre/modules/debug.js", this); +Cu.import("resource://gre/modules/osfile.jsm", this); + +XPCOMUtils.defineLazyServiceGetter(this, "gSessionStartup", + "@mozilla.org/browser/sessionstartup;1", "nsISessionStartup"); +XPCOMUtils.defineLazyServiceGetter(this, "gScreenManager", + "@mozilla.org/gfx/screenmanager;1", "nsIScreenManager"); +XPCOMUtils.defineLazyServiceGetter(this, "Telemetry", + "@mozilla.org/base/telemetry;1", "nsITelemetry"); +XPCOMUtils.defineLazyModuleGetter(this, "console", + "resource://gre/modules/Console.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "RecentWindow", + "resource:///modules/RecentWindow.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "AppConstants", + "resource://gre/modules/AppConstants.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "GlobalState", + "resource:///modules/sessionstore/GlobalState.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "PrivacyFilter", + "resource:///modules/sessionstore/PrivacyFilter.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "RunState", + "resource:///modules/sessionstore/RunState.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "ScratchpadManager", + "resource://devtools/client/scratchpad/scratchpad-manager.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "SessionSaver", + "resource:///modules/sessionstore/SessionSaver.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "SessionCookies", + "resource:///modules/sessionstore/SessionCookies.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "SessionFile", + "resource:///modules/sessionstore/SessionFile.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "TabAttributes", + "resource:///modules/sessionstore/TabAttributes.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "TabCrashHandler", + "resource:///modules/ContentCrashHandlers.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "TabState", + "resource:///modules/sessionstore/TabState.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "TabStateCache", + "resource:///modules/sessionstore/TabStateCache.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "TabStateFlusher", + "resource:///modules/sessionstore/TabStateFlusher.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Utils", + "resource://gre/modules/sessionstore/Utils.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "ViewSourceBrowser", + "resource://gre/modules/ViewSourceBrowser.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "AsyncShutdown", + "resource://gre/modules/AsyncShutdown.jsm"); + +/** + * |true| if we are in debug mode, |false| otherwise. + * Debug mode is controlled by preference browser.sessionstore.debug + */ +var gDebuggingEnabled = false; +function debug(aMsg) { + if (gDebuggingEnabled) { + aMsg = ("SessionStore: " + aMsg).replace(/\S{80}/g, "$&\n"); + Services.console.logStringMessage(aMsg); + } +} + +this.SessionStore = { + get promiseInitialized() { + return SessionStoreInternal.promiseInitialized; + }, + + get canRestoreLastSession() { + return SessionStoreInternal.canRestoreLastSession; + }, + + set canRestoreLastSession(val) { + SessionStoreInternal.canRestoreLastSession = val; + }, + + get lastClosedObjectType() { + return SessionStoreInternal.lastClosedObjectType; + }, + + 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); + }, + + duplicateTab: function ss_duplicateTab(aWindow, aTab, aDelta = 0) { + return SessionStoreInternal.duplicateTab(aWindow, aTab, aDelta); + }, + + getClosedTabCount: function ss_getClosedTabCount(aWindow) { + return SessionStoreInternal.getClosedTabCount(aWindow); + }, + + getClosedTabData: function ss_getClosedTabData(aWindow, aAsString = true) { + return SessionStoreInternal.getClosedTabData(aWindow, aAsString); + }, + + 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(aAsString = true) { + return SessionStoreInternal.getClosedWindowData(aAsString); + }, + + undoCloseWindow: function ss_undoCloseWindow(aIndex) { + return SessionStoreInternal.undoCloseWindow(aIndex); + }, + + forgetClosedWindow: function ss_forgetClosedWindow(aIndex) { + return SessionStoreInternal.forgetClosedWindow(aIndex); + }, + + getWindowValue: function ss_getWindowValue(aWindow, aKey) { + return SessionStoreInternal.getWindowValue(aWindow, aKey); + }, + + setWindowValue: function ss_setWindowValue(aWindow, aKey, aStringValue) { + SessionStoreInternal.setWindowValue(aWindow, aKey, aStringValue); + }, + + deleteWindowValue: function ss_deleteWindowValue(aWindow, aKey) { + SessionStoreInternal.deleteWindowValue(aWindow, aKey); + }, + + getTabValue: function ss_getTabValue(aTab, aKey) { + return SessionStoreInternal.getTabValue(aTab, aKey); + }, + + setTabValue: function ss_setTabValue(aTab, aKey, aStringValue) { + SessionStoreInternal.setTabValue(aTab, aKey, aStringValue); + }, + + deleteTabValue: function ss_deleteTabValue(aTab, aKey) { + SessionStoreInternal.deleteTabValue(aTab, aKey); + }, + + getGlobalValue: function ss_getGlobalValue(aKey) { + return SessionStoreInternal.getGlobalValue(aKey); + }, + + setGlobalValue: function ss_setGlobalValue(aKey, aStringValue) { + SessionStoreInternal.setGlobalValue(aKey, aStringValue); + }, + + deleteGlobalValue: function ss_deleteGlobalValue(aKey) { + SessionStoreInternal.deleteGlobalValue(aKey); + }, + + persistTabAttribute: function ss_persistTabAttribute(aName) { + SessionStoreInternal.persistTabAttribute(aName); + }, + + restoreLastSession: function ss_restoreLastSession() { + SessionStoreInternal.restoreLastSession(); + }, + + getCurrentState: function (aUpdateAll) { + return SessionStoreInternal.getCurrentState(aUpdateAll); + }, + + reviveCrashedTab(aTab) { + return SessionStoreInternal.reviveCrashedTab(aTab); + }, + + reviveAllCrashedTabs() { + return SessionStoreInternal.reviveAllCrashedTabs(); + }, + + navigateAndRestore(tab, loadArguments, historyIndex) { + return SessionStoreInternal.navigateAndRestore(tab, loadArguments, historyIndex); + }, + + getSessionHistory(tab, updatedCallback) { + return SessionStoreInternal.getSessionHistory(tab, updatedCallback); + }, + + undoCloseById(aClosedId) { + return SessionStoreInternal.undoCloseById(aClosedId); + }, + + /** + * 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; + }, +}; + +// Freeze the SessionStore object. We don't want anyone to modify it. +Object.freeze(SessionStore); + +var SessionStoreInternal = { + QueryInterface: XPCOMUtils.generateQI([ + Ci.nsIDOMEventListener, + Ci.nsIObserver, + Ci.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 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 -> nsIFrameLoader) 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(), + + // A map (xul:browser -> object) that maps a browser that is switching + // remoteness via navigateAndRestore, to the loadArguments that were + // most recently passed when calling navigateAndRestore. + _remotenessChangingBrowsers: new WeakMap(), + + // 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, + + // 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, + + // 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, + + // 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 = []; + let windowsEnum = Services.wm.getEnumerator("navigator:browser"); + while (windowsEnum.hasMoreElements()) { + let window = windowsEnum.getNext(); + 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"; + }, + + /** + * Initialize the sessionstore service. + */ + init: function () { + 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; + }, + + /** + * Initialize the session using the state provided by SessionStartup + */ + initSession: function () { + TelemetryStopwatch.start("FX_SESSION_RESTORE_STARTUP_INIT_SESSION_MS"); + let state; + let ss = gSessionStartup; + + if (ss.doRestore() || + ss.sessionType == Ci.nsISessionStartup.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 == Ci.nsISessionStartup.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) + state = iniState; + else + state = null; + + if (remainingState.windows.length) { + LastSession.setState(remainingState); + } + } + else { + // Get the last deferred session in case the user still wants to + // restore it + LastSession.setState(state.lastSessionState); + + if (ss.previousSessionCrashed) { + this._recentCrashes = (state.session && + state.session.recentCrashes || 0) + 1; + + 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}; + state = { windows: [{ tabs: [{ entries: [{url}], formdata }] }] }; + } else if (this._hasSingleTabWithURL(state.windows, + "about:welcomeback")) { + // 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"; + } + } + + // 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; + }); + } + } + catch (ex) { debug("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 (!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; + }, + + _initPrefs : function() { + this._prefBranch = Services.prefs.getBranch("browser."); + + gDebuggingEnabled = this._prefBranch.getBoolPref("sessionstore.debug"); + + Services.prefs.addObserver("browser.sessionstore.debug", () => { + gDebuggingEnabled = this._prefBranch.getBoolPref("sessionstore.debug"); + }, false); + + 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); + }, + + /** + * 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. + RunState.setClosing(); + + // save all data for session resuming + if (this._sessionInitialized) { + SessionSaver.run(); + } + + // clear out priority queue in case it's still holding refs + TabRestoreQueue.reset(); + + // Make sure to cancel pending saves. + 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); + 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(); + break; + case "browser:purge-domain-data": + this.onPurgeDomainData(aData); + break; + case "nsPref:changed": // catch pref changes + this.onPrefChange(aData); + break; + case "idle-daily": + this.onIdleDaily(); + break; + } + }, + + /** + * 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, 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; + } + + if (aMessage.data.isFinal) { + // 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. + TabStateFlusher.resolveAll(browser); + } else if (aMessage.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. + TabStateFlusher.resolve(browser, aMessage.data.flushID); + } + + // Ignore messages from elements that have crashed + // and not yet been revived. + if (this._crashedBrowsers.has(browser.permanentKey)) { + return; + } + + // Record telemetry measurements done in the child and update the tab's + // cached state. Mark the window as dirty and trigger a delayed write. + this.recordTelemetry(aMessage.data.telemetry); + TabState.update(browser, aMessage.data); + 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. + if (this._closedTabs.has(browser.permanentKey)) { + let {closedTabs, tabData} = this._closedTabs.get(browser.permanentKey); + + // 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. + TabState.copyFromCache(browser, tabData.state); + + // Is this the tab's final message? + if (aMessage.data.isFinal) { + // We expect no further updates. + this._closedTabs.delete(browser.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(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(closedTabs, index); + } + } + } + break; + case "SessionStore:restoreHistoryComplete": + // Notify the tabbrowser that the tab chrome has been restored. + let tabData = TabState.collect(tab); + + // wall-paper fix for bug 439675: make sure that the URL to be loaded + // is always visible in the address bar if no other value is present + let activePageData = tabData.entries[tabData.index - 1] || null; + let uri = activePageData ? activePageData.url || null : null; + // NB: we won't set initial URIs (about:home, about:newtab, etc.) here + // because their load will not normally trigger a location bar clearing + // when they finish loading (to avoid race conditions where we then + // clear user input instead), so we shouldn't set them here either. + // They also don't fall under the issues in bug 439675 where user input + // needs to be preserved if the load doesn't succeed. + // We also don't do this for remoteness updates, where it should not + // be necessary. + if (!browser.userTypedValue && uri && !data.isRemotenessUpdate && + !win.gInitialPages.includes(uri)) { + browser.userTypedValue = uri; + } + + // If the page has a title, set it. + if (activePageData) { + if (activePageData.title) { + tab.label = activePageData.title; + tab.crop = "end"; + } else if (activePageData.url != "about:blank") { + tab.label = activePageData.url; + tab.crop = "center"; + } + } else if (tab.hasAttribute("customizemode")) { + win.gCustomizeMode.setTab(tab); + } + + // Restore the tab icon. + if ("image" in tabData) { + // Use the serialized contentPrincipal with the new icon load. + let loadingPrincipal = Utils.deserializePrincipal(tabData.iconLoadingPrincipal); + win.gBrowser.setIcon(tab, tabData.image, loadingPrincipal); + TabStateCache.update(browser, { image: null, iconLoadingPrincipal: null }); + } + + let event = win.document.createEvent("Events"); + event.initEvent("SSTabRestoring", true, false); + tab.dispatchEvent(event); + break; + case "SessionStore:restoreTabContentStarted": + if (browser.__SS_restoreState == TAB_STATE_NEEDS_RESTORE) { + // 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 (!data.isRemotenessUpdate) { + // 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. + let tabData = TabState.collect(tab); + if (tabData.userTypedValue && !tabData.userTypedClear && !browser.userTypedValue) { + browser.userTypedValue = tabData.userTypedValue; + win.URLBarSetURI(); + } + + // Remove state we don't need any longer. + TabStateCache.update(browser, { + userTypedValue: null, userTypedClear: null + }); + } + break; + case "SessionStore:restoreTabContentComplete": + // 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, null); + } + + SessionStoreInternal._resetLocalTabRestoringState(tab); + SessionStoreInternal.restoreNextTab(); + + this._sendTabRestoredNotification(tab, data.isRemotenessUpdate); + break; + case "SessionStore:crashedTabRevived": + // The browser was revived by navigating to a different page + // manually, so we remove it from the ignored browser set. + this._crashedBrowsers.delete(browser.permanentKey); + break; + case "SessionStore:error": + this.reportInternalError(data); + TabStateFlusher.resolveAll(browser, false, "Received error from the content process"); + break; + default: + throw new Error(`received unknown message '${aMessage.name}'`); + break; + } + }, + + /** + * Record telemetry measurements stored in an object. + * @param telemetry + * {histogramID: value, ...} An object mapping histogramIDs to the + * value to be recorded for that ID, + */ + recordTelemetry: function (telemetry) { + for (let histogramId in telemetry){ + Telemetry.getHistogramById(histogramId).add(telemetry[histogramId]); + } + }, + + /* ........ Window Event Handlers .............. */ + + /** + * Implement nsIDOMEventListener 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.onTabClose(win, target); + this.onTabRemove(win, target); + 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": + this.onBrowserCrashed(target); + break; + case "XULFrameLoaderCreated": + if (target.namespaceURI == NS_XUL && + target.localName == "browser" && + target.frameLoader && + target.permanentKey) { + this._lastKnownFrameLoader.set(target.permanentKey, target.frameLoader); + this.resetEpoch(target); + } + 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 (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. + mm.loadFrameScript("chrome://browser/content/content-sessionStore.js", true); + + // and create its data object + this._windows[aWindow.__SSi] = { tabs: [], selected: 0, _closedTabs: [], busy: false }; + + 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 (RunState.isStopped) { + 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. + 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 = gSessionStartup.state; + + // Nothing to restore now, notify observers things are complete. + Services.obs.notifyObservers(null, NOTIFY_WINDOWS_RESTORED, ""); + } 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); + + 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, ""); + } + } + // this window was opened by _openWindowWithState + else if (!this._isWindowLoaded(aWindow)) { + let state = this._statesToRestore[aWindow.__SS_restoreID]; + let options = {overwriteTabs: true, isFollowUp: state.windows.length == 1}; + this.restoreWindow(aWindow, state.windows[0], options); + } + // 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" || !this._doResumeSession()) { + // 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._closedWindows.splice(closedWindowIndex, 1); + } + // 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._closedWindows.splice(closedWindowIndex, 1); + 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: function (aWindow) { + // Register the window. + this.onLoad(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", false); + }); + + // 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, gSessionStartup.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(); + } + }, console.error); + }, + + /** + * On window close... + * - remove event listeners from tabs + * - save all window data + * @param aWindow + * Window reference + */ + onClose: function ssi_onClose(aWindow) { + // 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(); + } + + this._windows[aWindow.__SSi] = this._statesToRestore[aWindow.__SS_restoreID]; + delete this._statesToRestore[aWindow.__SS_restoreID]; + delete aWindow.__SS_restoreID; + } + + // ignore windows not tracked by SessionStore + if (!aWindow.__SSi || !this._windows[aWindow.__SSi]) { + return; + } + + // 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 (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 = tabbrowser.selectedBrowser.contentTitle || tabbrowser.selectedTab.label; + winData.title = this._replaceLoadingTitle(winData.title, tabbrowser, + tabbrowser.selectedTab); + SessionCookies.update([winData]); + } + + 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 = + Object.keys(this._windows).length == 1 && + !this._closedWindows.some(win => win._shouldRestore || false); + + // 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. + PrivacyFilter.filterPrivateTabs(winData); + this.maybeSaveClosedWindow(winData, isLastWindow); + } + + 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. + + for (let browser of browsers) { + if (this._closedWindowTabs.has(browser.permanentKey)) { + let tabData = this._closedWindowTabs.get(browser.permanentKey); + TabState.copyFromCache(browser, 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. + 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(); + }); + } else { + this.cleanUpWindow(aWindow, winData, browsers); + } + + for (let i = 0; i < tabbrowser.tabs.length; i++) { + this.onTabRemove(aWindow, tabbrowser.tabs[i], true); + } + }, + + /** + * 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) { + 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 (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(); + } else if (!shouldStore && alreadyStored) { + this._closedWindows.splice(winIndex, 1); + } + } + }, + + /** + * On quit application granted + */ + onQuitApplicationGranted: function ssi_onQuitApplicationGranted(syncShutdown=false) { + // Collect an initial snapshot of window data before we do the flush + this._forEachBrowserWindow((win) => { + this._collectWindowData(win); + }); + + // 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. + RunState.setQuitting(); + + if (!syncShutdown) { + // We've got some time to shut down, so let's do this properly. + // To prevent blocker from breaking the 60 sec limit(which will cause a + // crash) of async shutdown during flushing all windows, we resolve the + // promise passed to blocker once: + // 1. the flushing exceed 50 sec, or + // 2. 'oop-frameloader-crashed' or 'ipc:content-shutdown' is observed. + // Thus, Firefox still can open the last session on next startup. + AsyncShutdown.quitApplicationGranted.addBlocker( + "SessionStore: flushing all windows", + () => { + var promises = []; + promises.push(this.flushAllWindowsAsync(progress)); + promises.push(this.looseTimer(50000)); + + var promiseOFC = new Promise(resolve => { + Services.obs.addObserver(function obs(subject, topic) { + Services.obs.removeObserver(obs, topic); + resolve(); + }, "oop-frameloader-crashed", false); + }); + promises.push(promiseOFC); + + var promiseICS = new Promise(resolve => { + Services.obs.addObserver(function obs(subject, topic) { + Services.obs.removeObserver(obs, topic); + resolve(); + }, "ipc:content-shutdown", false); + }); + promises.push(promiseICS); + + return Promise.race(promises); + }, + () => 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 + */ + flushAllWindowsAsync: Task.async(function*(progress={}) { + let windowPromises = new Map(); + // 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. + this._forEachBrowserWindow((win) => { + windowPromises.set(win, TabStateFlusher.flushWindow(win)); + + // 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 = win.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDocShell) + .QueryInterface(Ci.nsIDocShellTreeItem) + .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) { + yield promise; + this._collectWindowData(win); + progress.current++; + }; + + // We must cache this because _getMostRecentBrowserWindow will always + // return null by the time quit-application occurs. + var activeWindow = this._getMostRecentBrowserWindow(); + 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") { + 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 + LastSession.clear(); + } + + this._uninit(); + }, + + /** + * On purge of session history + */ + onPurgeSessionHistory: function ssi_onPurgeSessionHistory() { + 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 (RunState.isQuitting) + return; + LastSession.clear(); + + let openWindows = {}; + // Collect open windows. + this._forEachBrowserWindow(({__SSi: id}) => openWindows[id] = true); + + // also clear all data about closed tabs and windows + for (let ix in this._windows) { + if (ix in openWindows) { + this._windows[ix]._closedTabs = []; + } else { + delete this._windows[ix]; + } + } + // also clear all data about closed windows + this._closedWindows = []; + // give the tabbrowsers a chance to clear their histories first + var win = this._getMostRecentBrowserWindow(); + if (win) { + win.setTimeout(() => SessionSaver.run(), 0); + } else if (RunState.isRunning) { + SessionSaver.run(); + } + + this._clearRestoringWindows(); + this._saveableClosedWindowData = new WeakSet(); + }, + + /** + * On purge of domain data + * @param aData + * String domain data + */ + onPurgeDomainData: function ssi_onPurgeDomainData(aData) { + // does a session history entry contain a url for the given domain? + function containsDomain(aEntry) { + if (Utils.hasRootDomain(aEntry.url, aData)) { + 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); + } + } + // 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 == 0) { + 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 (RunState.isRunning) { + 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) { + this._windows[ix]._closedTabs.splice(this._max_tabs_undo, this._windows[ix]._closedTabs.length); + } + break; + case "sessionstore.max_windows_undo": + this._max_windows_undo = this._prefBranch.getIntPref("sessionstore.max_windows_undo"); + this._capClosedWindows(); + 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); + + if (browser.frameLoader) { + this._lastKnownFrameLoader.set(browser.permanentKey, browser.frameLoader); + } + }, + + /** + * 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) { + let browser = aTab.linkedBrowser; + browser.removeEventListener("SwapDocShells", this); + browser.removeEventListener("oop-browser-crashed", 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 = browser.__SS_restoreState; + if (previousState) { + this._resetTabRestoringState(aTab); + if (previousState == TAB_STATE_RESTORING) + this.restoreNextTab(); + } + + 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 = TabState.collect(aTab); + + // Don't save private tabs + let isPrivateWindow = PrivateBrowsingUtils.isWindowPrivate(aWindow); + if (!isPrivateWindow && tabState.isPrivate) { + return; + } + + // Store closed-tab data for undo. + let tabbrowser = aWindow.gBrowser; + let tabTitle = this._replaceLoadingTitle(aTab.label, tabbrowser, aTab); + let {permanentKey} = aTab.linkedBrowser; + + let tabData = { + permanentKey, + state: tabState, + title: tabTitle, + image: tabbrowser.getIcon(aTab), + iconLoadingPrincipal: Utils.serializePrincipal(aTab.linkedBrowser.contentPrincipal), + pos: aTab._tPos, + closedAt: Date.now() + }; + + let closedTabs = this._windows[aWindow.__SSi]._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(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, {closedTabs, tabData}); + }, + + /** + * 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 closedTabs (array) + * The list of closed tabs for a window. + * @param tabData (object) + * The tabData to be inserted. + */ + saveClosedTabData(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); + + // 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 closedTabs (array) + * The list of closed tabs for a window. + * @param index (uint) + * The index of the tab to remove. + */ + removeClosedTabData(closedTabs, index) { + // Remove the given index from the list. + let [closedTab] = closedTabs.splice(index, 1); + + // 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 (RunState.isRunning) { + this._windows[aWindow.__SSi].selected = aWindow.gBrowser.tabContainer.selectedIndex; + + let tab = aWindow.gBrowser.selectedTab; + let browser = tab.linkedBrowser; + + if (browser.__SS_restoreState && + browser.__SS_restoreState == TAB_STATE_NEEDS_RESTORE) { + // If __SS_restoreState is still on the browser and it is + // TAB_STATE_NEEDS_RESTORE, then 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 (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 (aTab.linkedBrowser.__SS_restoreState && + aTab.linkedBrowser.__SS_restoreState == 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 (aTab.linkedBrowser.__SS_restoreState && + aTab.linkedBrowser.__SS_restoreState == 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: function(aBrowser) { + NS_ASSERT(aBrowser.isRemoteBrowser, + "Only remote browsers should be able to crash"); + + this.enterCrashedState(aBrowser); + // The browser crashed so we might never receive flush responses. + // Resolve all pending flush requests for the crashed browser. + 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 (browser.__SS_restoreState) { + let tab = win.gBrowser.getTabForBrowser(browser); + 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: function() { + // 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)); + }, + + // Remove "old" data from an array + _cleanupOldData: function(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); + } + } + } + }, + + /* ........ 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._getMostRecentBrowserWindow(); + if (!window) { + this._restoreCount = 1; + this._openWindowWithState(state); + return; + } + + // close all other browser windows + this._forEachBrowserWindow(function(aWindow) { + if (aWindow != window) { + aWindow.close(); + this.onClose(aWindow); + } + }); + + // make sure closed window data isn't kept + this._closedWindows = []; + + // 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 to the given state + this.restoreWindows(window, state, {overwriteTabs: true}); + }, + + getWindowState: function ssi_getWindowState(aWindow) { + if ("__SSi" in aWindow) { + return JSON.stringify(this._getWindowState(aWindow)); + } + + if (DyingWindowCache.has(aWindow)) { + let data = DyingWindowCache.get(aWindow); + return JSON.stringify({ 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}); + }, + + getTabState: function ssi_getTabState(aTab) { + if (!aTab.ownerGlobal.__SSi) { + throw Components.Exception("Default view is not tracked", Cr.NS_ERROR_INVALID_ARG); + } + + let tabState = TabState.collect(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 = 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 (!("__SSi" in window)) { + throw Components.Exception("Window is not tracked", Cr.NS_ERROR_INVALID_ARG); + } + + if (aTab.linkedBrowser.__SS_restoreState) { + this._resetTabRestoringState(aTab); + } + + this.restoreTab(aTab, tabState); + }, + + duplicateTab: function ssi_duplicateTab(aWindow, aTab, aDelta = 0, aRestoreImmediately = true) { + 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 newTab = aTab == aWindow.gBrowser.selectedTab ? + aWindow.gBrowser.addTab(null, {relatedToCurrent: true, ownerTab: aTab, userContextId}) : + aWindow.gBrowser.addTab(null, {userContextId}); + + // Set tab title to "Connecting..." and start the throbber to pretend we're + // doing something while actually waiting for data from the frame script. + aWindow.gBrowser.setTabTitleLoading(newTab); + newTab.setAttribute("busy", "true"); + + // Collect state before flushing. + let tabState = TabState.clone(aTab); + + // Flush to get the latest tab state to duplicate. + let browser = aTab.linkedBrowser; + 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}; + TabState.copyFromCache(browser, tabState, options); + + tabState.index += aDelta; + tabState.index = Math.max(1, Math.min(tabState.index, tabState.entries.length)); + tabState.pinned = false; + + // Restore the state into the new tab. + this.restoreTab(newTab, tabState, { + restoreImmediately: aRestoreImmediately + }); + }); + + return newTab; + }, + + getClosedTabCount: function ssi_getClosedTabCount(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; + }, + + getClosedTabData: function ssi_getClosedTabData(aWindow, aAsString = true) { + if ("__SSi" in aWindow) { + return aAsString ? + JSON.stringify(this._windows[aWindow.__SSi]._closedTabs) : + 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 aAsString ? JSON.stringify(data._closedTabs) : 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); + } + + var closedTabs = this._windows[aWindow.__SSi]._closedTabs; + + // default to the most-recently closed tab + aIndex = aIndex || 0; + if (!(aIndex in 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(closedTabs, aIndex); + + // create a new tab + let tabbrowser = aWindow.gBrowser; + let tab = tabbrowser.selectedTab = tabbrowser.addTab(null, state); + + // restore tab content + this.restoreTab(tab, state); + + // restore the tab's position + tabbrowser.moveTabTo(tab, pos); + + // focus the tab's content area (bug 342432) + tab.linkedBrowser.focus(); + + return tab; + }, + + forgetClosedTab: function ssi_forgetClosedTab(aWindow, aIndex) { + if (!aWindow.__SSi) { + throw Components.Exception("Window is not tracked", Cr.NS_ERROR_INVALID_ARG); + } + + var closedTabs = this._windows[aWindow.__SSi]._closedTabs; + + // default to the most-recently closed tab + aIndex = aIndex || 0; + if (!(aIndex in closedTabs)) { + throw Components.Exception("Invalid index: not in the closed tabs", Cr.NS_ERROR_INVALID_ARG); + } + + // remove closed tab from the array + this.removeClosedTabData(closedTabs, aIndex); + }, + + getClosedWindowCount: function ssi_getClosedWindowCount() { + return this._closedWindows.length; + }, + + getClosedWindowData: function ssi_getClosedWindowData(aAsString = true) { + return aAsString ? JSON.stringify(this._closedWindows) : Cu.cloneInto(this._closedWindows, {}); + }, + + 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._closedWindows.splice(aIndex, 1) }; + delete state.windows[0].closedAt; // Window is now open. + + let window = this._openWindowWithState(state); + this.windowToFocus = window; + 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._closedWindows.splice(aIndex, 1); + this._saveableClosedWindowData.delete(winData); + }, + + getWindowValue: function ssi_getWindowValue(aWindow, aKey) { + if ("__SSi" in aWindow) { + var 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); + }, + + setWindowValue: function ssi_setWindowValue(aWindow, aKey, aStringValue) { + if (typeof aStringValue != "string") { + throw new TypeError("setWindowValue 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); + }, + + deleteWindowValue: function ssi_deleteWindowValue(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); + }, + + getTabValue: function ssi_getTabValue(aTab, aKey) { + return (aTab.__SS_extdata || {})[aKey] || ""; + }, + + setTabValue: function ssi_setTabValue(aTab, aKey, aStringValue) { + if (typeof aStringValue != "string") { + throw new TypeError("setTabValue only accepts string values"); + } + + // If the tab hasn't been restored, then set the data there, otherwise we + // could lose newly added data. + if (!aTab.__SS_extdata) { + aTab.__SS_extdata = {}; + } + + aTab.__SS_extdata[aKey] = aStringValue; + this.saveStateDelayed(aTab.ownerGlobal); + }, + + deleteTabValue: function ssi_deleteTabValue(aTab, aKey) { + if (aTab.__SS_extdata && aKey in aTab.__SS_extdata) { + delete aTab.__SS_extdata[aKey]; + this.saveStateDelayed(aTab.ownerGlobal); + } + }, + + getGlobalValue: function ssi_getGlobalValue(aKey) { + return this._globalState.get(aKey); + }, + + setGlobalValue: function ssi_setGlobalValue(aKey, aStringValue) { + if (typeof aStringValue != "string") { + throw new TypeError("setGlobalValue only accepts string values"); + } + + this._globalState.set(aKey, aStringValue); + this.saveStateDelayed(); + }, + + deleteGlobalValue: function ssi_deleteGlobalValue(aKey) { + this._globalState.delete(aKey); + this.saveStateDelayed(); + }, + + persistTabAttribute: function ssi_persistTabAttribute(aName) { + if (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 + * + * @returns a tab or window object + */ + undoCloseById(aClosedId) { + // 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. + let windowsEnum = Services.wm.getEnumerator("navigator:browser"); + while (windowsEnum.hasMoreElements()) { + let window = windowsEnum.getNext(); + 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; + }, + + /** + * 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 = {}; + this._forEachBrowserWindow(function(aWindow) { + if (aWindow.__SS_lastSessionWindowID) + windows[aWindow.__SS_lastSessionWindowID] = aWindow; + }); + + 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._getMostRecentBrowserWindow(); + 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); + + // 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); + } + + // Restore into that window - pretend it's a followup since we'll already + // have a focused window. + //XXXzpao This is going to merge extData together (taking what was in + // winState over what is in the window already. + let options = {overwriteTabs: canOverwriteTabs, isFollowUp: true}; + this.restoreWindow(windowToUse, winState, options); + } + else { + this._openWindowWithState({ windows: [winState] }); + } + } + + // Merge closed windows from this session with ones from last session + if (lastSessionState._closedWindows) { + this._closedWindows = this._closedWindows.concat(lastSessionState._closedWindows); + this._capClosedWindows(); + } + + if (lastSessionState.scratchpads) { + ScratchpadManager.restoreSession(lastSessionState.scratchpads); + } + + // 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(); + }, + + /** + * 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."); + } + + 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"); + browser.loadURI("about:blank", null, null); + + let data = TabState.collect(aTab); + this.restoreTab(aTab, data, { + forceOnDemand: true, + }); + }, + + /** + * Revive all crashed tabs and reset the crashed tabs count to 0. + */ + reviveAllCrashedTabs() { + let windowsEnum = Services.wm.getEnumerator("navigator:browser"); + while (windowsEnum.hasMoreElements()) { + let window = windowsEnum.getNext(); + for (let tab of window.gBrowser.tabs) { + this.reviveCrashedTab(tab); + } + } + }, + + /** + * Navigate the given |tab| by first collecting its current state and then + * either changing only the index of the currently shown history entry, + * or restoring the exact same state again and passing the new URL to load + * in |loadArguments|. Use this method to seamlessly switch between pages + * loaded in the parent and pages loaded in the child process. + * + * This method might be called multiple times before it has finished + * flushing the browser tab. If that occurs, the loadArguments from + * the most recent call to navigateAndRestore will be used once the + * flush has finished. + */ + navigateAndRestore(tab, loadArguments, historyIndex) { + let window = tab.ownerGlobal; + NS_ASSERT(window.__SSi, "tab's window must be tracked"); + let browser = tab.linkedBrowser; + + // Were we already waiting for a flush from a previous call to + // navigateAndRestore on this tab? + let alreadyRestoring = + this._remotenessChangingBrowsers.has(browser.permanentKey); + + // Stash the most recent loadArguments in this WeakMap so that + // we know to use it when the TabStateFlusher.flush resolves. + this._remotenessChangingBrowsers.set(browser.permanentKey, loadArguments); + + if (alreadyRestoring) { + // This tab was already being restored to run in the + // correct process. We're done here. + return; + } + + // Set tab title to "Connecting..." and start the throbber to pretend we're + // doing something while actually waiting for data from the frame script. + window.gBrowser.setTabTitleLoading(tab); + tab.setAttribute("busy", "true"); + + // Flush to get the latest tab state. + TabStateFlusher.flush(browser).then(() => { + // loadArguments might have been overwritten by multiple calls + // to navigateAndRestore while we waited for the tab to flush, + // so we use the most recently stored one. + let recentLoadArguments = + this._remotenessChangingBrowsers.get(browser.permanentKey); + this._remotenessChangingBrowsers.delete(browser.permanentKey); + + // The tab might have been closed/gone in the meantime. + if (tab.closing || !tab.linkedBrowser) { + return; + } + + let window = tab.ownerGlobal; + + // The tab or its window might be gone. + if (!window || !window.__SSi || window.closed) { + return; + } + + let tabState = TabState.clone(tab); + let options = { + restoreImmediately: true, + // We want to make sure that this information is passed to restoreTab + // whether or not a historyIndex is passed in. Thus, we extract it from + // the loadArguments. + reloadInFreshProcess: !!recentLoadArguments.reloadInFreshProcess, + }; + + if (historyIndex >= 0) { + tabState.index = historyIndex + 1; + tabState.index = Math.max(1, Math.min(tabState.index, tabState.entries.length)); + } else { + options.loadArguments = recentLoadArguments; + } + + // Need to reset restoring tabs. + if (tab.linkedBrowser.__SS_restoreState) { + this._resetLocalTabRestoringState(tab); + } + + // Restore the state into the tab. + this.restoreTab(tab, tabState, options); + }); + + tab.linkedBrowser.__SS_restoreState = TAB_STATE_WILL_RESTORE; + }, + + /** + * 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) { + 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 = TabState.collect(tab); + return { index: tabState.index - 1, entries: tabState.entries } + } + }, + + /** + * 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 normalTabsLen = tabbrowser.tabs.length - tabbrowser._numPinnedTabs; + let startupPref = this._prefBranch.getIntPref("startup.page"); + if (startupPref == 1) + homePages = homePages.concat(aWindow.gHomeButton.getHomePage().split("|")); + + for (let i = tabbrowser._numPinnedTabs; i < tabbrowser.tabs.length; i++) { + let tab = tabbrowser.tabs[i]; + if (homePages.indexOf(tab.linkedBrowser.currentURI.spec) != -1) { + removableTabs.push(tab); + } + } + + 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); + + var hidden = WINDOW_HIDEABLE_FEATURES.filter(function(aItem) { + return aWindow[aItem] && !aWindow[aItem].visible; + }); + if (hidden.length != 0) + winData.hidden = hidden.join(","); + else if (winData.hidden) + delete winData.hidden; + + var sidebar = aWindow.document.getElementById("sidebar-box").getAttribute("sidebarcommand"); + if (sidebar) + winData.sidebar = sidebar; + else if (winData.sidebar) + delete winData.sidebar; + }, + + /** + * gather session data as object + * @param aUpdateAll + * Bool update all windows + * @returns object + */ + getCurrentState: function (aUpdateAll) { + this._handleClosedWindows(); + + var activeWindow = this._getMostRecentBrowserWindow(); + + TelemetryStopwatch.start("FX_SESSION_RESTORE_COLLECT_ALL_WINDOWS_DATA_MS"); + if (RunState.isRunning) { + // update the data for all windows with activities since the last save operation + this._forEachBrowserWindow(function(aWindow) { + if (!this._isWindowLoaded(aWindow)) // window data is still in _statesToRestore + return; + if (aUpdateAll || DirtyWindows.has(aWindow) || aWindow == activeWindow) { + this._collectWindowData(aWindow); + } + else { // always update the window features (whose change alone never triggers a save operation) + this._updateWindowFeatures(aWindow); + } + }); + 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++; + } + + TelemetryStopwatch.start("FX_SESSION_RESTORE_COLLECT_COOKIES_MS"); + SessionCookies.update(total); + TelemetryStopwatch.finish("FX_SESSION_RESTORE_COLLECT_COOKIES_MS"); + + // 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 > 0 && + 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 > 0) + } + } + + 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: session, + global: this._globalState.getState() + }; + + if (Cu.isModuleLoaded("resource://devtools/client/scratchpad/scratchpad-manager.jsm")) { + // get open Scratchpad window states too + let scratchpads = ScratchpadManager.getSessionState(); + if (scratchpads && scratchpads.length) { + state.scratchpads = scratchpads; + } + } + + // 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[aWindow.__SS_restoreID]; + + if (RunState.isRunning) { + this._collectWindowData(aWindow); + } + + let windows = [this._windows[aWindow.__SSi]]; + SessionCookies.update(windows); + + return { windows: windows }; + }, + + /** + * 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) { + let tabData = TabState.collect(tab); + tabMap.set(tab, tabData); + tabsData.push(tabData); + } + winData.selected = tabbrowser.mTabBox.selectedIndex + 1; + + 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 .............. */ + + /** + * restore features to a single window + * @param aWindow + * Window reference to the window to use for restoration + * @param winData + * JS object + * @param aOptions + * {overwriteTabs: true} to overwrite existing tabs w/ new ones + * {isFollowUp: true} if this is not the restoration of the 1st window + * {firstWindow: true} 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 isFollowUp = aOptions && aOptions.isFollowUp; + let firstWindow = aOptions && aOptions.firstWindow; + + if (isFollowUp) { + this.windowToFocus = aWindow; + } + + // 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._setWindowStateBusy(aWindow); + + 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 == 0)) { + winData.tabs = []; + } + + var tabbrowser = aWindow.gBrowser; + var openTabCount = overwriteTabs ? tabbrowser.browsers.length : -1; + var newTabCount = winData.tabs.length; + var tabs = []; + + // disable smooth scrolling while adding, moving, removing and selecting tabs + var tabstrip = tabbrowser.tabContainer.mTabstrip; + var smoothScroll = tabstrip.smoothScroll; + tabstrip.smoothScroll = false; + + // unpin all tabs to ensure they are not reordered in the next loop + if (overwriteTabs) { + for (let t = tabbrowser._numPinnedTabs - 1; t > -1; t--) + tabbrowser.unpinTab(tabbrowser.tabs[t]); + } + + // 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.slice(tabbrowser.tabs); + } + + // make sure that the selected tab won't be closed in order to + // prevent unnecessary flickering + if (overwriteTabs && tabbrowser.selectedTab._tPos >= newTabCount) + tabbrowser.moveTabTo(tabbrowser.selectedTab, newTabCount - 1); + + let numVisibleTabs = 0; + + for (var t = 0; t < newTabCount; t++) { + // When trying to restore into existing tab, we also take the userContextId + // into account if present. + let userContextId = winData.tabs[t].userContextId; + let reuseExisting = t < openTabCount && + (tabbrowser.tabs[t].getAttribute("usercontextid") == (userContextId || "")); + // If the tab is pinned, then we'll be loading it right away, and + // there's no need to cause a remoteness flip by loading it initially + // non-remote. + let forceNotRemote = !winData.tabs[t].pinned; + let tab = reuseExisting ? tabbrowser.tabs[t] : + tabbrowser.addTab("about:blank", + {skipAnimation: true, + forceNotRemote, + userContextId}); + + // If we inserted a new tab because the userContextId didn't match with the + // open tab, even though `t < openTabCount`, we need to remove that open tab + // and put the newly added tab in its place. + if (!reuseExisting && t < openTabCount) { + tabbrowser.removeTab(tabbrowser.tabs[t]); + tabbrowser.moveTabTo(tab, t); + } + + tabs.push(tab); + + if (winData.tabs[t].pinned) + tabbrowser.pinTab(tabs[t]); + + if (winData.tabs[t].hidden) { + tabbrowser.hideTab(tabs[t]); + } + else { + tabbrowser.showTab(tabs[t]); + numVisibleTabs++; + } + + if (!!winData.tabs[t].muted != tabs[t].linkedBrowser.audioMuted) { + tabs[t].toggleMuteAudio(winData.tabs[t].muteReason); + } + } + + if (!overwriteTabs && firstWindow) { + // Move the originally open tabs to the end + let endPosition = tabbrowser.tabs.length - 1; + for (let i = 0; i < initialTabs.length; i++) { + tabbrowser.moveTabTo(initialTabs[i], endPosition); + } + } + + // if all tabs to be restored are hidden, make the first one visible + if (!numVisibleTabs && winData.tabs.length) { + winData.tabs[0].hidden = false; + tabbrowser.showTab(tabs[0]); + } + + // If overwriting tabs, we want to reset each tab's "restoring" state. Since + // we're overwriting those tabs, they should no longer be restoring. The + // tabs will be rebuilt and marked if they need to be restored after loading + // state (in restoreTabs). + if (overwriteTabs) { + for (let i = 0; i < tabbrowser.tabs.length; i++) { + let tab = tabbrowser.tabs[i]; + if (tabbrowser.browsers[i].__SS_restoreState) + this._resetTabRestoringState(tab); + } + } + + // 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; + + // when overwriting tabs, remove all superflous ones + if (overwriteTabs && newTabCount < openTabCount) { + Array.slice(tabbrowser.tabs, newTabCount, openTabCount) + .forEach(tabbrowser.removeTab, tabbrowser); + } + + if (overwriteTabs) { + this.restoreWindowFeatures(aWindow, winData); + delete this._windows[aWindow.__SSi].extData; + } + if (winData.cookies) { + 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 || []; + + 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); + } + + // Restore tabs, if any. + if (winData.tabs.length) { + this.restoreTabs(aWindow, tabs, winData.tabs, + (overwriteTabs ? (parseInt(winData.selected || "1")) : 0)); + } + + // set smoothScroll back to the original value + tabstrip.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(); + }, + + /** + * 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: true} to overwrite existing tabs w/ new ones + * {isFollowUp: true} if this is not the restoration of the 1st window + * {firstWindow: true} 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 = {}) { + let isFollowUp = aOptions && aOptions.isFollowUp; + + if (isFollowUp) { + this.windowToFocus = aWindow; + } + + // 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 + debug(ex); + this._sendRestoreCompletedNotifications(); + return; + } + + // Restore closed windows if any. + if (root._closedWindows) { + this._closedWindows = root._closedWindows; + } + + // We're done here if there are no windows. + if (!root.windows || !root.windows.length) { + this._sendRestoreCompletedNotifications(); + return; + } + + if (!root.selectedWindow || root.selectedWindow > root.windows.length) { + root.selectedWindow = 0; + } + + // open new windows for all further window entries of a multi-window session + // (unless they don't contain any tab data) + let winData; + for (var w = 1; w < root.windows.length; w++) { + winData = root.windows[w]; + if (winData && winData.tabs && winData.tabs[0]) { + var window = this._openWindowWithState({ windows: [winData] }); + if (w == root.selectedWindow - 1) { + this.windowToFocus = window; + } + } + } + + this.restoreWindow(aWindow, root.windows[0], aOptions); + + if (aState.scratchpads) { + ScratchpadManager.restoreSession(aState.scratchpads); + } + }, + + /** + * 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; + + if (!this._isWindowLoaded(aWindow)) { + // from now on, the data will come from the actual window + delete this._statesToRestore[aWindow.__SS_restoreID]; + delete aWindow.__SS_restoreID; + delete this._windows[aWindow.__SSi]._restoring; + } + + 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); + } + + // Let the tab data array have the right number of slots. + tabsDataArray.length = numTabsInWindow; + + // If provided, set the selected tab. + if (aSelectTab > 0 && aSelectTab <= aTabs.length) { + tabbrowser.selectedTab = aTabs[aSelectTab - 1]; + + // Update the window state in case we shut down without being notified. + this._windows[aWindow.__SSi].selected = aSelectTab; + } + + // Restore all tabs. + for (let t = 0; t < aTabs.length; t++) { + this.restoreTab(aTabs[t], aTabData[t]); + } + }, + + // Restores the given tab state for a given tab. + restoreTab(tab, tabData, options = {}) { + NS_ASSERT(!tab.linkedBrowser.__SS_restoreState, + "must reset tab before calling restoreTab()"); + + let restoreImmediately = options.restoreImmediately; + let loadArguments = options.loadArguments; + let browser = tab.linkedBrowser; + let window = tab.ownerGlobal; + let tabbrowser = window.gBrowser; + let forceOnDemand = options.forceOnDemand; + let reloadInFreshProcess = options.reloadInFreshProcess; + + let willRestoreImmediately = restoreImmediately || + tabbrowser.selectedBrowser == browser || + loadArguments; + + if (!willRestoreImmediately && !forceOnDemand) { + TabRestoreQueue.add(tab); + } + + this._maybeUpdateBrowserRemoteness({ tabbrowser, tab, + willRestoreImmediately }); + + // 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); + + // 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. + for (let otherTab of Array.slice(tabbrowser.tabs, 0, tab._tPos)) { + let emptyState = {entries: [], lastAccessed: otherTab.lastAccessed}; + this._windows[window.__SSi].tabs.push(emptyState); + } + + // 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 pin/unpin + // and show/hide tabs as necessary. We'll also attach a copy of the tab's + // data in case we close it before it's been restored. + 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 (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 => TabAttributes.persist(a)); + } + + if (!tabData.entries) { + tabData.entries = []; + } + if (tabData.extData) { + tab.__SS_extdata = Cu.cloneInto(tabData.extData, {}); + } else { + delete tab.__SS_extdata; + } + + // 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; + + // 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); + + // keep the data around to prevent dataloss in case + // a tab gets closed before it's been properly restored + browser.__SS_restoreState = TAB_STATE_NEEDS_RESTORE; + browser.setAttribute("pending", "true"); + 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); + + // Update the persistent tab state cache with |tabData| information. + TabStateCache.update(browser, { + history: {entries: tabData.entries, index: tabData.index}, + scroll: tabData.scroll || null, + storage: tabData.storage || null, + formdata: tabData.formdata || null, + disallow: tabData.disallow || null, + pageStyle: tabData.pageStyle || null, + + // 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, + userTypedValue: tabData.userTypedValue || "", + userTypedClear: tabData.userTypedClear || 0 + }); + + browser.messageManager.sendAsyncMessage("SessionStore:restoreHistory", + {tabData: tabData, epoch: epoch, loadArguments}); + + // Restore tab attributes. + if ("attributes" in tabData) { + TabAttributes.set(tab, tabData.attributes); + } + + // 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, loadArguments, reloadInFreshProcess); + } else if (!forceOnDemand) { + this.restoreNextTab(); + } + + // 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 aLoadArguments + * optional load arguments used for loadURI() + * @param aReloadInFreshProcess + * true if we want to reload into a fresh process + */ + restoreTabContent: function (aTab, aLoadArguments = null, aReloadInFreshProcess = false) { + if (aTab.hasAttribute("customizemode") && !aLoadArguments) { + return; + } + + let browser = aTab.linkedBrowser; + let window = aTab.ownerGlobal; + let tabbrowser = window.gBrowser; + let tabData = TabState.clone(aTab); + let activeIndex = tabData.index - 1; + let activePageData = tabData.entries[activeIndex] || null; + let uri = activePageData ? activePageData.url || null : null; + if (aLoadArguments) { + uri = aLoadArguments.uri; + if (aLoadArguments.userContextId) { + browser.setAttribute("usercontextid", aLoadArguments.userContextId); + } + } + + // We have to mark this tab as restoring first, otherwise + // the "pending" attribute will be applied to the linked + // browser, which removes it from the display list. We cannot + // flip the remoteness of any browser that is not being displayed. + this.markTabAsRestoring(aTab); + + let isRemotenessUpdate = false; + if (aReloadInFreshProcess) { + isRemotenessUpdate = tabbrowser.switchBrowserIntoFreshProcess(browser); + } else { + 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); + + browser.messageManager.sendAsyncMessage("SessionStore:restoreHistory", { + tabData: tabData, + epoch: epoch, + loadArguments: aLoadArguments, + isRemotenessUpdate, + }); + + } + + // If the restored browser wants to show view source content, start up a + // view source browser that will load the required frame script. + if (uri && ViewSourceBrowser.isViewSource(uri)) { + new ViewSourceBrowser(browser); + } + + browser.messageManager.sendAsyncMessage("SessionStore:restoreTabContent", + {loadArguments: aLoadArguments, isRemotenessUpdate}); + }, + + /** + * Marks a given pending tab as restoring. + * + * @param aTab + * the pending tab to mark as restoring + */ + markTabAsRestoring(aTab) { + let browser = aTab.linkedBrowser; + if (browser.__SS_restoreState != 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 + browser.__SS_restoreState = TAB_STATE_RESTORING; + browser.removeAttribute("pending"); + 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 (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.indexOf(aItem) == -1; + }); + + if (aWinData.isPopup) { + this._windows[aWindow.__SSi].isPopup = true; + if (aWindow.gURLBar) { + aWindow.gURLBar.readOnly = true; + aWindow.gURLBar.setAttribute("enablehistory", "false"); + } + } + else { + delete this._windows[aWindow.__SSi].isPopup; + if (aWindow.gURLBar) { + aWindow.gURLBar.readOnly = false; + aWindow.gURLBar.setAttribute("enablehistory", "true"); + } + } + + var _this = this; + aWindow.setTimeout(function() { + _this.restoreDimensions.apply(_this, [aWindow, + +(aWinData.width || 0), + +(aWinData.height || 0), + "screenX" in aWinData ? +aWinData.screenX : NaN, + "screenY" in aWinData ? +aWinData.screenY : NaN, + aWinData.sizemode || "", aWinData.sidebar || ""]); + }, 0); + }, + + /** + * Restore a window's dimensions + * @param aWidth + * Window width + * @param aHeight + * Window height + * @param aLeft + * Window left + * @param aTop + * Window top + * @param aSizeMode + * Window size mode (eg: maximized) + * @param aSidebar + * Sidebar command + */ + restoreDimensions: function ssi_restoreDimensions(aWindow, aWidth, aHeight, aLeft, aTop, aSizeMode, aSidebar) { + var win = aWindow; + var _this = this; + function win_(aName) { return _this._getWindowDimension(win, aName); } + + // find available space on the screen where this window is being placed + let screen = gScreenManager.screenForRect(aLeft, aTop, aWidth, aHeight); + if (screen) { + let screenLeft = {}, screenTop = {}, screenWidth = {}, screenHeight = {}; + screen.GetAvailRectDisplayPix(screenLeft, screenTop, screenWidth, screenHeight); + // screenX/Y are based on the origin of the screen's desktop-pixel coordinate space + let screenLeftCss = screenLeft.value; + let screenTopCss = screenTop.value; + // convert screen's device pixel dimensions to CSS px dimensions + screen.GetAvailRect(screenLeft, screenTop, screenWidth, screenHeight); + let cssToDevScale = screen.defaultCSSScaleFactor; + let screenRightCss = screenLeftCss + screenWidth.value / cssToDevScale; + let screenBottomCss = screenTopCss + screenHeight.value / cssToDevScale; + + // 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 < screenLeftCss - SCREEN_EDGE_SLOP) { + aLeft = screenLeftCss; + } + // Then check the resulting right edge, and reduce it if necessary. + let right = aLeft + aWidth; + if (right > screenRightCss + SCREEN_EDGE_SLOP) { + right = screenRightCss; + // See if we can move the left edge leftwards to maintain width. + if (aLeft > screenLeftCss) { + aLeft = Math.max(right - aWidth, screenLeftCss); + } + } + // Finally, update aWidth to account for the adjusted left and right edges. + aWidth = right - aLeft; + + // And do the same in the vertical dimension. + if (aTop < screenTopCss - SCREEN_EDGE_SLOP) { + aTop = screenTopCss; + } + let bottom = aTop + aHeight; + if (bottom > screenBottomCss + SCREEN_EDGE_SLOP) { + bottom = screenBottomCss; + if (aTop > screenTopCss) { + aTop = Math.max(bottom - aHeight, screenTopCss); + } + } + aHeight = bottom - aTop; + } + + // only modify those aspects which aren't correct yet + if (!isNaN(aLeft) && !isNaN(aTop) && (aLeft != win_("screenX") || aTop != win_("screenY"))) { + aWindow.moveTo(aLeft, aTop); + } + if (aWidth && aHeight && (aWidth != win_("width") || aHeight != win_("height"))) { + // 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); + } + } + if (aSizeMode && win_("sizemode") != aSizeMode) + { + switch (aSizeMode) + { + case "maximized": + aWindow.maximize(); + break; + case "minimized": + aWindow.minimize(); + break; + case "normal": + aWindow.restore(); + break; + } + } + var sidebar = aWindow.document.getElementById("sidebar-box"); + if (sidebar.getAttribute("sidebarcommand") != aSidebar) { + aWindow.SidebarUI.show(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(); + } + }, + + /* ........ 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: function (aWindow = null) { + if (aWindow) { + DirtyWindows.add(aWindow); + } + + SessionSaver.runDelayed(); + }, + + /* ........ Auxiliary Functions .............. */ + + /** + * Determines whether or not a tab that is being restored needs + * to have its remoteness flipped first. + * + * @param (object) with the following properties: + * + * tabbrowser (): + * The tabbrowser that the browser belongs to. + * + * tab (): + * The tab being restored + * + * willRestoreImmediately (bool): + * true if the tab is going to have its content + * restored immediately by the caller. + * + */ + _maybeUpdateBrowserRemoteness({ tabbrowser, tab, + willRestoreImmediately }) { + // If the browser we're attempting to restore happens to be + // remote, we need to flip it back to non-remote if it's going + // to go into the pending background tab state. This is to make + // sure that a background tab can't crash if it hasn't yet + // been restored. + // + // Normally, when a window is restored, the tabs that SessionStore + // inserts are non-remote - but the initial browser is, by default, + // remote, so this check and flip covers this case. The other case + // is when window state is overwriting the state of an existing + // window with some remote tabs. + let browser = tab.linkedBrowser; + + // There are two ways that a tab might start restoring its content + // very soon - either the caller is going to restore the content + // immediately, or the TabRestoreQueue is set up so that the tab + // content is going to be restored in the very near future. In + // either case, we don't want to flip remoteness, since the browser + // will soon be loading content. + let willRestore = willRestoreImmediately || + TabRestoreQueue.willRestoreSoon(tab); + + if (browser.isRemoteBrowser && !willRestore) { + tabbrowser.updateBrowserRemoteness(browser, false); + } + }, + + /** + * 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; + } + }, + + /** + * call a callback for all currently opened browser windows + * (might miss the most recent one) + * @param aFunc + * Callback each window is passed to + */ + _forEachBrowserWindow: function ssi_forEachBrowserWindow(aFunc) { + var windowsEnum = Services.wm.getEnumerator("navigator:browser"); + + while (windowsEnum.hasMoreElements()) { + var window = windowsEnum.getNext(); + if (window.__SSi && !window.closed) { + aFunc.call(this, window); + } + } + }, + + /** + * Returns most recent window + * @returns Window reference + */ + _getMostRecentBrowserWindow: function ssi_getMostRecentBrowserWindow() { + return RecentWindow.getMostRecentBrowserWindow({ 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() { + var windowsEnum = Services.wm.getEnumerator("navigator:browser"); + + while (windowsEnum.hasMoreElements()) { + var window = windowsEnum.getNext(); + if (window.closed) { + this.onClose(window); + } + } + }, + + /** + * 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 = "chrome,dialog=no,macsuppressanimation,all"; + let winState = aState.windows[0]; + WINDOW_ATTRIBUTES.forEach(function(aFeature) { + // Use !isNaN as an easy way to ignore sizemode and check for numbers + if (aFeature in winState && !isNaN(winState[aFeature])) + features += "," + aFeature + "=" + winState[aFeature]; + }); + + if (winState.isPrivate) { + features += ",private"; + } + + var window = + Services.ww.openWindow(null, this._prefBranch.getCharPref("chromeURL"), + "_blank", features, argString); + + do { + var ID = "window" + Math.random(); + } while (ID in this._statesToRestore); + this._statesToRestore[(window.__SS_restoreID = ID)] = aState; + + return window; + }, + + /** + * Whether or not to resume session, if not recovering from a crash. + * @returns bool + */ + _doResumeSession: function ssi_doResumeSession() { + return this._prefBranch.getIntPref("startup.page") == 3 || + this._prefBranch.getBoolPref("sessionstore.resume_session_once"); + }, + + /** + * 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 XULWindow'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"; + } + } + + var dimension; + switch (aAttribute) { + case "width": + dimension = aWindow.outerWidth; + break; + case "height": + dimension = aWindow.outerHeight; + break; + default: + dimension = aAttribute in aWindow ? aWindow[aAttribute] : ""; + break; + } + + if (aWindow.windowState == aWindow.STATE_NORMAL) { + return dimension; + } + return aWindow.document.documentElement.getAttribute(aAttribute) || dimension; + }, + + /** + * @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 == 0) + 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); + + return max_resumed_crashes != -1 && + (aRecentCrashes > max_resumed_crashes || + sessionAge && sessionAge >= SIX_HOURS_IN_MS); + }, + + /** + * @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: function(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); + }, + + /** + * This is going to take a state as provided at startup (via + * nsISessionStartup.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 contain the cookies that go along with the history + * entries in those tabs. 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 nsISessionStartup.state + * @returns [defaultState, state] + */ + _prepDataForDeferredRestore: function ssi_prepDataForDeferredRestore(state) { + // Make sure that we don't modify the global state as provided by + // nsSessionStartup.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: [], cookies: []}; + 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 + 2; + // + 2 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(); + + // Extract the cookies that belong with each pinned tab + this._splitCookiesFromWindow(window, pinnedWindowState); + + // 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]; + }, + + /** + * Splits out the cookies from aWinState into aTargetWinState based on the + * tabs that are in aTargetWinState. + * This alters the state of aWinState and aTargetWinState. + */ + _splitCookiesFromWindow: + function ssi_splitCookiesFromWindow(aWinState, aTargetWinState) { + if (!aWinState.cookies || !aWinState.cookies.length) + return; + + // Get the hosts for history entries in aTargetWinState + let cookieHosts = SessionCookies.getHostsForWindow(aTargetWinState); + + // By creating a regex we reduce overhead and there is only one loop pass + // through either array (cookieHosts and aWinState.cookies). + let hosts = Object.keys(cookieHosts).join("|").replace(/\./g, "\\."); + // If we don't actually have any hosts, then we don't want to do anything. + if (!hosts.length) + return; + let cookieRegex = new RegExp(".*(" + hosts + ")"); + for (let cIndex = 0; cIndex < aWinState.cookies.length;) { + if (cookieRegex.test(aWinState.cookies[cIndex].host)) { + aTargetWinState.cookies = + aTargetWinState.cookies.concat(aWinState.cookies.splice(cIndex, 1)); + continue; + } + cIndex++; + } + }, + + _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. + Services.obs.notifyObservers(null, + this._browserSetState ? NOTIFY_BROWSER_STATE_RESTORED : NOTIFY_WINDOWS_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[aWindow.__SS_restoreID].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._sendWindowStateEvent(aWindow, "Ready"); + } + }, + + /** + * 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._sendWindowStateEvent(aWindow, "Busy"); + } + }, + + /** + * Dispatch an SSWindowState_____ event for the given window. + * @param aWindow the window + * @param aType the type of event, SSWindowState will be prepended to this string + */ + _sendWindowStateEvent: function ssi_sendWindowStateEvent(aWindow, aType) { + let event = aWindow.document.createEvent("Events"); + event.initEvent("SSWindowState" + aType, 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 !aWindow.__SS_restoreID; + }, + + /** + * Replace "Loading..." with the tab label (with minimal side-effects) + * @param aString is the string the title is stored in + * @param aTabbrowser is a tabbrowser object, containing aTab + * @param aTab is the tab whose title we're updating & using + * + * @returns aString that has been updated with the new title + */ + _replaceLoadingTitle : function ssi_replaceLoadingTitle(aString, aTabbrowser, aTab) { + if (aString == aTabbrowser.mStringBundle.getString("tabs.connecting")) { + aTabbrowser.setTabTitle(aTab); + [aString, aTab.label] = [aTab.label, aString]; + } + return aString; + }, + + /** + * 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; + } + this._closedWindows.splice(spliceTo, this._closedWindows.length); + }, + + /** + * 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: function (aTab) { + NS_ASSERT(aTab.linkedBrowser.__SS_restoreState, + "given tab is not restoring"); + + let browser = aTab.linkedBrowser; + + // Keep the tab's previous state for later in this method + let previousState = browser.__SS_restoreState; + + // The browser is no longer in any sort of restoring state. + delete browser.__SS_restoreState; + + aTab.removeAttribute("pending"); + browser.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: function (tab) { + NS_ASSERT(tab.linkedBrowser.__SS_restoreState, + "given tab is not restoring"); + + let browser = tab.linkedBrowser; + 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(browser) { + let next = this.getCurrentEpoch(browser) + 1; + this._browserEpochs.set(browser.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(browser) { + return this._browserEpochs.get(browser.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: function (browser, epoch) { + return this.getCurrentEpoch(browser) == 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(browser) { + this._browserEpochs.delete(browser.permanentKey); + }, + + /** + * Handle an error report from a content process. + */ + reportInternalError(data) { + // For the moment, we only report errors through Telemetry. + if (data.telemetry) { + for (let key of Object.keys(data.telemetry)) { + let histogram = Telemetry.getHistogramById(key); + histogram.add(data.telemetry[key]); + } + } + }, + + /** + * 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 promise = new Promise(resolve => { + timer.initWithCallback(function() { + if (beats <= 0) { + 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. + promise.then(() => timer.cancel(), () => timer.cancel()); + return promise; + } +}; + +/** + * 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: value, configurable: true}; + Object.defineProperty(this, "restoreOnDemand", definition); + return value; + } + + const PREF = "browser.sessionstore.restore_on_demand"; + Services.prefs.addObserver(PREF, updateValue, false); + 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: value, configurable: true}; + Object.defineProperty(this, "restorePinnedTabsOnDemand", definition); + return value; + } + + const PREF = "browser.sessionstore.restore_pinned_tabs_on_demand"; + Services.prefs.addObserver(PREF, updateValue, false); + 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: value, configurable: true}; + Object.defineProperty(this, "restoreHiddenTabs", definition); + return value; + } + + const PREF = "browser.sessionstore.restore_hidden_tabs"; + Services.prefs.addObserver(PREF, updateValue, false); + return updateValue(); + } + }, + + // Resets the queue and removes all tabs. + reset: function () { + this.tabs = {priority: [], visible: [], hidden: []}; + }, + + // Adds a tab to the queue and determines its priority bucket. + add: function (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: function (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: function () { + 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: function (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: function (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: function (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: function (window) { + return this._data.has(window); + }, + + get: function (window) { + return this._data.get(window); + }, + + set: function (window, data) { + this._data.set(window, data); + }, + + remove: function (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: function (window) { + return this._data.has(window); + }, + + add: function (window) { + return this._data.set(window, true); + }, + + remove: function (window) { + this._data.delete(window); + }, + + clear: function (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: function () { + return this._state; + }, + + setState: function (state) { + this._state = state; + }, + + clear: function () { + if (this._state) { + this._state = null; + Services.obs.notifyObservers(null, NOTIFY_LAST_SESSION_CLEARED, null); + } + } +}; diff --git a/browser/components/sessionstore/SessionWorker.js b/browser/components/sessionstore/SessionWorker.js new file mode 100644 index 000000000..7d802a7df --- /dev/null +++ b/browser/components/sessionstore/SessionWorker.js @@ -0,0 +1,381 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 worker dedicated to handle I/O for Session Store. + */ + +"use strict"; + +importScripts("resource://gre/modules/osfile.jsm"); + +var PromiseWorker = require("resource://gre/modules/workers/PromiseWorker.js"); + +var File = OS.File; +var Encoder = new TextEncoder(); +var Decoder = new TextDecoder(); + +var worker = new PromiseWorker.AbstractWorker(); +worker.dispatch = function(method, args = []) { + return Agent[method](...args); +}; +worker.postMessage = function(result, ...transfers) { + self.postMessage(result, ...transfers); +}; +worker.close = function() { + self.close(); +}; + +self.addEventListener("message", msg => worker.handleMessage(msg)); + +// The various possible states + +/** + * 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.recoverBackupy` (we haven't written + * anything to disk yet). Both `Paths.clean` and `Paths.recovery` are + * absent or invalid. The backup directory exists. + */ +const STATE_RECOVERY_BACKUP = "recoveryBackup"; +/** + * 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 Agent = { + // Path to the files used by the SessionWorker + Paths: null, + + /** + * The current state of the worker, as one of the following strings: + * - "permanent", once the first write has been completed; + * - "empty", before the first write has been completed, + * if we have started without any sessionstore; + * - one of "clean", "recovery", "recoveryBackup", "cleanBackup", + * "upgradeBackup", before the first write has been completed, if + * we have started by loading the corresponding file. + */ + state: null, + + /** + * Number of old upgrade backups that are being kept + */ + maxUpgradeBackups: null, + + /** + * Initialize (or reinitialize) the worker + * + * @param {string} origin Which of sessionstore.js or its backups + * was used. One of the `STATE_*` constants defined above. + * @param {object} paths The paths at which to find the various files. + * @param {object} prefs The preferences the worker needs to known. + */ + init(origin, 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.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 + */ + write: function (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; + } + } + } + + let stateString = JSON.stringify(state); + let data = Encoder.encode(stateString); + + 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. + File.makeDir(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. + File.move(this.Paths.clean, this.Paths.cleanBackup); + } + + let startWriteMs = Date.now(); + + 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. + File.writeAtomic(this.Paths.clean, data, { + tmpPath: this.Paths.clean + ".tmp" + }); + } 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. + File.writeAtomic(this.Paths.recovery, data, { + tmpPath: this.Paths.recovery + ".tmp", + backupTo: this.Paths.recoveryBackup + }); + } 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. + File.writeAtomic(this.Paths.recovery, data, { + tmpPath: this.Paths.recovery + ".tmp" + }); + } + + telemetry.FX_SESSION_RESTORE_WRITE_FILE_MS = Date.now() - startWriteMs; + telemetry.FX_SESSION_RESTORE_FILE_SIZE_BYTES = data.byteLength; + + } 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; + File.copy(path, this.Paths.nextUpgradeBackup); + this.upgradeBackupNeeded = false; + upgradeBackupComplete = true; + } catch (ex) { + // Don't throw immediately + exn = exn || ex; + } + + // Find all backups + let iterator; + let backups = []; // array that will contain the paths to all upgrade backup + let upgradeBackupPrefix = this.Paths.upgradeBackupPrefix; // access for forEach callback + + try { + iterator = new File.DirectoryIterator(this.Paths.backups); + iterator.forEach(function (file) { + if (file.path.startsWith(upgradeBackupPrefix)) { + backups.push(file.path); + } + }, this); + } catch (ex) { + // Don't throw immediately + exn = exn || ex; + } finally { + if (iterator) { + iterator.close(); + } + } + + // If too many backups exist, delete them + if (backups.length > this.maxUpgradeBackups) { + // Use alphanumerical sort since dates are in YYYYMMDDHHMMSS format + backups.sort().forEach((file, i) => { + // remove backup file if it is among the first (n-maxUpgradeBackups) files + if (i < backups.length - this.maxUpgradeBackups) { + File.remove(file); + } + }); + } + } + + 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. + File.remove(this.Paths.recoveryBackup); + File.remove(this.Paths.recovery); + } + + this.state = STATE_RECOVERY; + + if (exn) { + throw exn; + } + + return { + result: { + upgradeBackup: upgradeBackupComplete + }, + telemetry: telemetry, + }; + }, + + /** + * Wipes all files holding session data from disk. + */ + wipe: function () { + + // Don't stop immediately in case of error. + let exn = null; + + // Erase main session state file + try { + File.remove(this.Paths.clean); + } catch (ex) { + // Don't stop immediately. + exn = exn || ex; + } + + // Wipe the Session Restore directory + try { + this._wipeFromDir(this.Paths.backups, null); + } catch (ex) { + exn = exn || ex; + } + + try { + File.removeDir(this.Paths.backups); + } catch (ex) { + exn = exn || ex; + } + + // Wipe legacy Ression Restore files from the profile directory + try { + this._wipeFromDir(OS.Constants.Path.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|null} prefix If provided, only remove files whose + * name starts with a specific prefix. + */ + _wipeFromDir: function(path, prefix) { + // Sanity check + if (typeof prefix == "undefined" || prefix == "") { + throw new TypeError(); + } + + let exn = null; + + let iterator = new File.DirectoryIterator(path); + try { + if (!iterator.exists()) { + return; + } + for (let entry in iterator) { + if (entry.isDir) { + continue; + } + if (!prefix || entry.name.startsWith(prefix)) { + try { + File.remove(entry.path); + } catch (ex) { + // Don't stop immediately + exn = exn || ex; + } + } + } + + if (exn) { + throw exn; + } + } finally { + iterator.close(); + } + }, +}; + +function isNoSuchFileEx(aReason) { + return aReason instanceof OS.File.Error && aReason.becauseNoSuchFile; +} + +/** + * Estimate the number of bytes that a data structure will use on disk + * once serialized. + */ +function getByteLength(str) { + return Encoder.encode(JSON.stringify(str)).byteLength; +} diff --git a/browser/components/sessionstore/SessionWorker.jsm b/browser/components/sessionstore/SessionWorker.jsm new file mode 100644 index 000000000..b26e531ac --- /dev/null +++ b/browser/components/sessionstore/SessionWorker.jsm @@ -0,0 +1,25 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/** + * Interface to a dedicated thread handling I/O + */ + +const Cu = Components.utils; +const Cc = Components.classes; +const Ci = Components.interfaces; +const Cr = Components.results; + +Cu.import("resource://gre/modules/PromiseWorker.jsm", this); +Cu.import("resource://gre/modules/osfile.jsm", this); + +this.EXPORTED_SYMBOLS = ["SessionWorker"]; + +this.SessionWorker = new BasePromiseWorker("resource:///modules/sessionstore/SessionWorker.js"); +// As the Session Worker performs I/O, we can receive instances of +// OS.File.Error, so we need to install a decoder. +this.SessionWorker.ExceptionHandlers["OS.File.Error"] = OS.File.Error.fromMsg; + diff --git a/browser/components/sessionstore/StartupPerformance.jsm b/browser/components/sessionstore/StartupPerformance.jsm new file mode 100644 index 000000000..d1b77a237 --- /dev/null +++ b/browser/components/sessionstore/StartupPerformance.jsm @@ -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/. */ + +"use strict"; + +this.EXPORTED_SYMBOLS = ["StartupPerformance"]; + +const { utils: Cu, classes: Cc, interfaces: Ci } = Components; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm", this); + +XPCOMUtils.defineLazyModuleGetter(this, "Services", + "resource://gre/modules/Services.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "console", + "resource://gre/modules/Console.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "setTimeout", + "resource://gre/modules/Timer.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "clearTimeout", + "resource://gre/modules/Timer.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Promise", + "resource://gre/modules/Promise.jsm"); + +const COLLECT_RESULTS_AFTER_MS = 10000; + +const OBSERVED_TOPICS = ["sessionstore-restoring-on-startup", "sessionstore-initiating-manual-restore"]; + +this.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: function() { + for (let topic of OBSERVED_TOPICS) { + Services.obs.addObserver(this, topic, false); + } + }, + + /** + * 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: function(isAutoRestore) { + 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", false); + 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: function() { + if (this._hasFired) { + return; + } + if (this._deadlineTimer) { + clearTimeout(this._deadlineTimer); + } + this._deadlineTimer = 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: function(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) { + 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.jsm b/browser/components/sessionstore/TabAttributes.jsm new file mode 100644 index 000000000..8a29680f4 --- /dev/null +++ b/browser/components/sessionstore/TabAttributes.jsm @@ -0,0 +1,74 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +this.EXPORTED_SYMBOLS = ["TabAttributes"]; + +// 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. +// 'iconLoadingPrincipal' is same as 'image' that it should be handled by +// using the gBrowser.getIcon()/setIcon() methods. +const ATTRIBUTES_TO_SKIP = new Set(["image", "muted", "pending", "iconLoadingPrincipal"]); + +// 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. +this.TabAttributes = Object.freeze({ + persist: function (name) { + return TabAttributesInternal.persist(name); + }, + + get: function (tab) { + return TabAttributesInternal.get(tab); + }, + + set: function (tab, data = {}) { + TabAttributesInternal.set(tab, data); + } +}); + +var TabAttributesInternal = { + _attrs: new Set(), + + persist: function (name) { + if (this._attrs.has(name) || ATTRIBUTES_TO_SKIP.has(name)) { + return false; + } + + this._attrs.add(name); + return true; + }, + + get: function (tab) { + let data = {}; + + for (let name of this._attrs) { + if (tab.hasAttribute(name)) { + data[name] = tab.getAttribute(name); + } + } + + return data; + }, + + set: function (tab, data = {}) { + // Clear attributes. + for (let name of this._attrs) { + tab.removeAttribute(name); + } + + // Set attributes. + for (let name in data) { + if (!ATTRIBUTES_TO_SKIP.has(name)) { + tab.setAttribute(name, data[name]); + } + } + } +}; + diff --git a/browser/components/sessionstore/TabState.jsm b/browser/components/sessionstore/TabState.jsm new file mode 100644 index 000000000..f22c52fe3 --- /dev/null +++ b/browser/components/sessionstore/TabState.jsm @@ -0,0 +1,196 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +this.EXPORTED_SYMBOLS = ["TabState"]; + +const Cu = Components.utils; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm", this); + +XPCOMUtils.defineLazyModuleGetter(this, "PrivacyFilter", + "resource:///modules/sessionstore/PrivacyFilter.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "TabStateCache", + "resource:///modules/sessionstore/TabStateCache.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "TabAttributes", + "resource:///modules/sessionstore/TabAttributes.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Utils", + "resource://gre/modules/sessionstore/Utils.jsm"); + +/** + * Module that contains tab state collection methods. + */ +this.TabState = Object.freeze({ + update: function (browser, data) { + TabStateInternal.update(browser, data); + }, + + collect: function (tab) { + return TabStateInternal.collect(tab); + }, + + clone: function (tab) { + return TabStateInternal.clone(tab); + }, + + copyFromCache(browser, tabData, options) { + TabStateInternal.copyFromCache(browser, tabData, options); + }, +}); + +var TabStateInternal = { + /** + * Processes a data update sent by the content script. + */ + update: function (browser, {data}) { + TabStateCache.update(browser, data); + }, + + /** + * Collect data related to a single tab, synchronously. + * + * @param tab + * tabbrowser tab + * + * @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: function (tab) { + return this._collectBaseTabData(tab); + }, + + /** + * Collect data related to a single tab, including private data. + * Use with caution. + * + * @param tab + * tabbrowser tab + * + * @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: function (tab) { + return this._collectBaseTabData(tab, {includePrivateData: true}); + }, + + /** + * Collects basic tab data for a given tab. + * + * @param tab + * tabbrowser tab + * @param options (object) + * {includePrivateData: true} to always include private data + * + * @returns {object} An object with the basic data for this tab. + */ + _collectBaseTabData: function (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; + } + + // Save tab attributes. + tabData.attributes = TabAttributes.get(tab); + + if (tab.__SS_extdata) { + tabData.extData = tab.__SS_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, 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); + } + + // Store the serialized contentPrincipal of this tab to use for the icon. + if (!("iconLoadingPrincipal" in tabData)) { + tabData.iconLoadingPrincipal = Utils.serializePrincipal(browser.contentPrincipal); + } + + // 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 browser (xul:browser) + * 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(browser, tabData, options = {}) { + let data = TabStateCache.get(browser); + if (!data) { + return; + } + + // The caller may explicitly request to omit privacy checks. + let includePrivateData = options && options.includePrivateData; + let isPinned = !!tabData.pinned; + + 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 = PrivacyFilter.filterSessionStorageData(value); + } else if (key === "formdata") { + value = PrivacyFilter.filterFormData(value); + } + } + + if (key === "history") { + tabData.entries = value.entries; + + if (value.hasOwnProperty("userContextId")) { + tabData.userContextId = value.userContextId; + } + + if (value.hasOwnProperty("index")) { + tabData.index = value.index; + } + } else { + tabData[key] = value; + } + } + } +}; diff --git a/browser/components/sessionstore/TabStateCache.jsm b/browser/components/sessionstore/TabStateCache.jsm new file mode 100644 index 000000000..9bed315a0 --- /dev/null +++ b/browser/components/sessionstore/TabStateCache.jsm @@ -0,0 +1,163 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +this.EXPORTED_SYMBOLS = ["TabStateCache"]; + +/** + * 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. + */ +this.TabStateCache = Object.freeze({ + /** + * Retrieves cached data for a given |tab| or associated |browser|. + * + * @param browserOrTab (xul:tab or xul:browser) + * The tab or browser to retrieve cached data for. + * @return (object) + * The cached data stored for the given |tab| + * or associated |browser|. + */ + get: function (browserOrTab) { + return TabStateCacheInternal.get(browserOrTab); + }, + + /** + * Updates cached data for a given |tab| or associated |browser|. + * + * @param browserOrTab (xul:tab or xul:browser) + * 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: function (browserOrTab, newData) { + TabStateCacheInternal.update(browserOrTab, newData); + } +}); + +var TabStateCacheInternal = { + _data: new WeakMap(), + + /** + * Retrieves cached data for a given |tab| or associated |browser|. + * + * @param browserOrTab (xul:tab or xul:browser) + * The tab or browser to retrieve cached data for. + * @return (object) + * The cached data stored for the given |tab| + * or associated |browser|. + */ + get: function (browserOrTab) { + return this._data.get(browserOrTab.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: function (data, change) { + if (!data.storage) { + data.storage = {}; + } + + let storage = data.storage; + for (let domain of Object.keys(change)) { + 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: function (data, change) { + const kLastIndex = Number.MAX_SAFE_INTEGER - 1; + + if (!data.history) { + data.history = { entries: [] }; + } + + let history = data.history; + for (let key of Object.keys(change)) { + if (key == "entries") { + if (change.fromIdx != kLastIndex) { + history.entries.splice(change.fromIdx + 1); + while (change.entries.length) { + history.entries.push(change.entries.shift()); + } + } + } else if (key != "fromIndex") { + history[key] = change[key]; + } + } + }, + + /** + * Updates cached data for a given |tab| or associated |browser|. + * + * @param browserOrTab (xul:tab or xul:browser) + * 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: function (browserOrTab, newData) { + let data = this._data.get(browserOrTab.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(browserOrTab.permanentKey, data); + } +}; diff --git a/browser/components/sessionstore/TabStateFlusher.jsm b/browser/components/sessionstore/TabStateFlusher.jsm new file mode 100644 index 000000000..6397efe9d --- /dev/null +++ b/browser/components/sessionstore/TabStateFlusher.jsm @@ -0,0 +1,184 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +this.EXPORTED_SYMBOLS = ["TabStateFlusher"]; + +const Cu = Components.utils; + +Cu.import("resource://gre/modules/Promise.jsm", this); + +/** + * 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. + */ +this.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. + _requests: new WeakMap(), + + /** + * 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 mm = browser.messageManager; + mm.sendAsyncMessage("SessionStore:flush", {id}); + + // Retrieve active requests for given browser. + let permanentKey = browser.permanentKey; + let perBrowserRequests = this._requests.get(permanentKey) || new Map(); + + return new Promise(resolve => { + // Store resolve() so that we can resolve the promise later. + perBrowserRequests.set(id, resolve); + + // Update the flush requests stored per browser. + this._requests.set(permanentKey, perBrowserRequests); + }); + }, + + /** + * 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) { + let browsers = window.gBrowser.browsers; + let promises = browsers.map((browser) => 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) { + Cu.reportError("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 active requests for given browser. + let perBrowserRequests = this._requests.get(browser.permanentKey); + + if (!success) { + Cu.reportError("Failed to flush browser: " + message); + } + + // Resolve all requests. + for (let resolve of perBrowserRequests.values()) { + resolve(success); + } + + // Clear active requests. + perBrowserRequests.clear(); + } +}; diff --git a/browser/components/sessionstore/content/aboutSessionRestore.js b/browser/components/sessionstore/content/aboutSessionRestore.js new file mode 100644 index 000000000..cc8d2da0b --- /dev/null +++ b/browser/components/sessionstore/content/aboutSessionRestore.js @@ -0,0 +1,362 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +var {classes: Cc, interfaces: Ci, utils: Cu} = Components; + +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "AppConstants", + "resource://gre/modules/AppConstants.jsm"); + +var gStateObject; +var gTreeData; + +// Page initialization + +window.onload = function() { + // pages used by this script may have a link that needs to be updated to + // the in-product link. + let anchor = document.getElementById("linkMoreTroubleshooting"); + if (anchor) { + let baseURL = Services.urlFormatter.formatURLPref("app.support.baseURL"); + anchor.setAttribute("href", baseURL + "troubleshooting"); + } + + // 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); + } + } + + // 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) { + document.getElementById("errorTryAgain").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(); + + document.getElementById("errorTryAgain").focus(); +}; + +function isTreeViewVisible() { + let tabList = document.querySelector(".tree-container"); + return tabList.hasAttribute("available"); +} + +function initTreeView() { + // If we aren't visible we initialize as we are made visible (and it's OK + // to initialize multiple times) + if (!isTreeViewVisible()) { + return; + } + var tabList = document.getElementById("tabList"); + var winLabel = tabList.getAttribute("_window_label"); + + gTreeData = []; + gStateObject.windows.forEach(function(aWinData, aIx) { + var winState = { + label: winLabel.replace("%S", (aIx + 1)), + 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); +} + +// User actions +function updateTabListVisibility() { + let tabList = document.querySelector(".tree-container"); + let container = document.querySelector(".container"); + if (document.getElementById("radioRestoreChoose").checked) { + tabList.setAttribute("available", "true"); + container.classList.add("restore-chosen"); + } else { + tabList.removeAttribute("available"); + container.classList.remove("restore-chosen"); + } + 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 ss = Cc["@mozilla.org/browser/sessionstore;1"].getService(Ci.nsISessionStore); + var top = getBrowserWindow(); + + // if there's only this page open, reuse the window for restoring the session + if (top.gBrowser.tabs.length == 1) { + ss.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"); + + var obs = Cc["@mozilla.org/observer-service;1"].getService(Ci.nsIObserverService); + obs.addObserver(function observe(win, topic) { + if (win != newWindow) { + return; + } + + obs.removeObserver(observe, topic); + ss.setWindowState(newWindow, stateString, true); + + var tabbrowser = top.gBrowser; + var tabIndex = tabbrowser.getBrowserIndexForDocument(document); + tabbrowser.removeTab(tabbrowser.tabs[tabIndex]); + }, "browser-delayed-startup-finished", false); +} + +function startNewSession() { + var prefBranch = Cc["@mozilla.org/preferences-service;1"].getService(Ci.nsIPrefBranch); + if (prefBranch.getIntPref("browser.startup.page") == 0) + getBrowserWindow().gBrowser.loadURI("about:blank"); + 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.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIWebNavigation) + .QueryInterface(Ci.nsIDocShellTreeItem).rootTreeItem + .QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIDOMWindow); +} + +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") + item.parent.checked = item.parent.tabs.every(isChecked) ? true : + item.parent.tabs.some(isChecked) ? 0 : false; + 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.addTab(); + var item = gTreeData[aIx]; + + var ss = Cc["@mozilla.org/browser/sessionstore;1"].getService(Ci.nsISessionStore); + 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; + ss.setTabState(newTab, JSON.stringify(tabState)); + + // respect the preference as to whether to select the tab (the Shift key inverses) + var prefBranch = Cc["@mozilla.org/preferences-service;1"].getService(Ci.nsIPrefBranch); + if (prefBranch.getBoolPref("browser.tabs.loadInBackground") != !aShifted) + tabbrowser.selectedTab = newTab; +} + +// Tree controller + +var treeView = { + treeBox: null, + selection: null, + + get rowCount() { return gTreeData.length; }, + setTree: function(treeBox) { this.treeBox = treeBox; }, + getCellText: function(idx, column) { return gTreeData[idx].label; }, + isContainer: function(idx) { return "open" in gTreeData[idx]; }, + getCellValue: function(idx, column){ return gTreeData[idx].checked; }, + isContainerOpen: function(idx) { return gTreeData[idx].open; }, + isContainerEmpty: function(idx) { return false; }, + isSeparator: function(idx) { return false; }, + isSorted: function() { return false; }, + isEditable: function(idx, column) { return false; }, + canDrop: function(idx, orientation, dt) { return false; }, + getLevel: function(idx) { return this.isContainer(idx) ? 0 : 1; }, + + getParentIndex: function(idx) { + if (!this.isContainer(idx)) + for (var t = idx - 1; t >= 0 ; t--) + if (this.isContainer(t)) + return t; + return -1; + }, + + hasNextSibling: function(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: function(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); + for (var t = idx + 1; t < gTreeData.length && this.getLevel(t) > thisLevel; t++); + 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: function(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: function(idx) { + var winState = gTreeData[idx].parent || gTreeData[idx]; + if (winState.ix % 2 != 0) + return "alternate"; + + return ""; + }, + + getImageSrc: function(idx, column) { + if (column.id == "title") + return gTreeData[idx].src || null; + return null; + }, + + getProgressMode : function(idx, column) { }, + cycleHeader: function(column) { }, + cycleCell: function(idx, column) { }, + selectionChanged: function() { }, + performAction: function(action) { }, + performActionOnCell: function(action, index, column) { }, + getColumnProperties: function(column) { return ""; } +}; diff --git a/browser/components/sessionstore/content/aboutSessionRestore.xhtml b/browser/components/sessionstore/content/aboutSessionRestore.xhtml new file mode 100644 index 000000000..bcd9084e7 --- /dev/null +++ b/browser/components/sessionstore/content/aboutSessionRestore.xhtml @@ -0,0 +1,86 @@ + + + + %htmlDTD; + + %netErrorDTD; + + %globalDTD; + + %restorepageDTD; +]> + + + + &restorepage.tabtitle; + + + + + + 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 000000000..f5775cd5b --- /dev/null +++ b/browser/components/sessionstore/test/browser_248970_b_perwindowpb.js @@ -0,0 +1,166 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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(), + "//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(function* () { + for (let win of windowsToClose) { + yield BrowserTestUtils.closeWindow(win); + } + }); + + function test(aLambda) { + try { + return aLambda() || true; + } catch(ex) { } + return false; + } + + function getElementByXPath(aTab, aQuery) { + let doc = aTab.linkedBrowser.contentDocument; + let xptype = Ci.nsIDOMXPathResult.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.forEach(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 (node instanceof Ci.nsIDOMHTMLInputElement) + return aValue == (node.type == "checkbox" || node.type == "radio" ? + node.checked : node.value); + if (node instanceof Ci.nsIDOMHTMLTextAreaElement) + return aValue == node.value; + if (!node.multiple) + return aValue == node.selectedIndex; + return Array.every(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.getClosedTabCount(aWin); + let max_tabs_undo = + Services.prefs.getIntPref("browser.sessionstore.max_tabs_undo"); + ok(0 <= count && count <= max_tabs_undo, + "getClosedTabCount should return zero or at most max_tabs_undo"); + + // setup a state for tab (A) so we can check later that is restored + let key = "key"; + let value = "Value " + Math.random(); + let state = { entries: [{ url: testURL }], extData: { key: value } }; + + // public session, add new tab: (A) + let tab_A = aWin.gBrowser.addTab(testURL); + ss.setTabState(tab_A, JSON.stringify(state)); + promiseBrowserLoaded(tab_A.linkedBrowser).then(() => { + // make sure that the next closed tab will increase getClosedTabCount + 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.getClosedTabCount(aWin) > count, + "getClosedTabCount has increased after closing a tab"); + + // verify tab: (A), in undo list + let tab_A_restored = test(() => 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(aWin) { + windowsToClose.push(aWin); + + // 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 = aWin.gBrowser.addTab(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 = aWin.gBrowser.duplicateTab(tab_B); + promiseTabRestored(tab_C).then(() => { + // verify the correctness of the duplicated tab + is(ss.getTabValue(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) + aWin.gBrowser.removeTab(tab_C); + aWin.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 000000000..76c3ae1aa --- /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 000000000..c38a6cb18 --- /dev/null +++ b/browser/components/sessionstore/test/browser_339445.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/. */ + +add_task(function* test() { + /** Test for Bug 339445 **/ + + let testURL = "http://mochi.test:8888/browser/" + + "browser/components/sessionstore/test/browser_339445_sample.html"; + + let tab = gBrowser.addTab(testURL); + yield promiseBrowserLoaded(tab.linkedBrowser); + + yield ContentTask.spawn(tab.linkedBrowser, null, function() { + let doc = content.document; + is(doc.getElementById("storageTestItem").textContent, "PENDING", + "sessionStorage value has been set"); + }); + + let tab2 = gBrowser.duplicateTab(tab); + yield promiseTabRestored(tab2); + + yield ContentTask.spawn(tab2.linkedBrowser, null, function() { + let doc2 = content.document; + is(doc2.getElementById("storageTestItem").textContent, "SUCCESS", + "sessionStorage value has been duplicated"); + }); + + // clean up + yield Promise.all([ 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 000000000..32656a8d9 --- /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 000000000..bd4a46e69 --- /dev/null +++ b/browser/components/sessionstore/test/browser_345898.js @@ -0,0 +1,44 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +function test() { + /** Test for Bug 345898 **/ + + function test(aLambda) { + try { + aLambda(); + return false; + } + catch (ex) { + return ex.name == "NS_ERROR_ILLEGAL_VALUE" || + ex.name == "NS_ERROR_FAILURE"; + } + } + + // all of the following calls with illegal arguments should throw NS_ERROR_ILLEGAL_VALUE + ok(test(() => ss.getWindowState({})), + "Invalid window for getWindowState throws"); + ok(test(() => ss.setWindowState({}, "", false)), + "Invalid window for setWindowState throws"); + ok(test(() => ss.getTabState({})), + "Invalid tab for getTabState throws"); + ok(test(() => ss.setTabState({}, "{}")), + "Invalid tab state for setTabState throws"); + ok(test(() => ss.setTabState({}, JSON.stringify({ entries: [] }))), + "Invalid tab for setTabState throws"); + ok(test(() => ss.duplicateTab({}, {})), + "Invalid tab for duplicateTab throws"); + ok(test(() => ss.duplicateTab({}, gBrowser.selectedTab)), + "Invalid window for duplicateTab throws"); + ok(test(() => ss.getClosedTabData({})), + "Invalid window for getClosedTabData throws"); + ok(test(() => ss.undoCloseTab({}, 0)), + "Invalid window for undoCloseTab throws"); + ok(test(() => ss.undoCloseTab(window, -1)), + "Invalid index for undoCloseTab throws"); + ok(test(() => ss.getWindowValue({}, "")), + "Invalid window for getWindowValue throws"); + ok(test(() => ss.setWindowValue({}, "", "")), + "Invalid window for setWindowValue throws"); +} diff --git a/browser/components/sessionstore/test/browser_350525.js b/browser/components/sessionstore/test/browser_350525.js new file mode 100644 index 000000000..1d87b3754 --- /dev/null +++ b/browser/components/sessionstore/test/browser_350525.js @@ -0,0 +1,102 @@ +"use strict"; + +add_task(function* setup() { + yield SpecialPowers.pushPrefEnv({ + set: [["dom.ipc.processCount", 1]] + }); +}) + +add_task(function* () { + /** Test for Bug 350525 **/ + + function test(aLambda) { + try { + return aLambda() || true; + } + catch (ex) { } + return false; + } + + //////////////////////////// + // setWindowValue, et al. // + //////////////////////////// + let key = "Unique name: " + Date.now(); + let value = "Unique value: " + Math.random(); + + // test adding + ok(test(() => ss.setWindowValue(window, key, value)), "set a window value"); + + // test retrieving + is(ss.getWindowValue(window, key), value, "stored window value matches original"); + + // test deleting + ok(test(() => ss.deleteWindowValue(window, key)), "delete the window value"); + + // value should not exist post-delete + is(ss.getWindowValue(window, key), "", "window value was deleted"); + + // test deleting a non-existent value + ok(test(() => ss.deleteWindowValue(window, key)), "delete non-existent window value"); + + ///////////////////////// + // setTabValue, et al. // + ///////////////////////// + key = "Unique name: " + Math.random(); + value = "Unique value: " + Date.now(); + let tab = gBrowser.addTab(); + tab.linkedBrowser.stop(); + + // test adding + ok(test(() => ss.setTabValue(tab, key, value)), "store a tab value"); + + // test retrieving + is(ss.getTabValue(tab, key), value, "stored tab value match original"); + + // test deleting + ok(test(() => ss.deleteTabValue(tab, key)), "delete the tab value"); + + // value should not exist post-delete + is(ss.getTabValue(tab, key), "", "tab value was deleted"); + + // test deleting a non-existent value + ok(test(() => ss.deleteTabValue(tab, key)), "delete non-existent tab value"); + + // clean up + yield promiseRemoveTab(tab); + + ///////////////////////////////////// + // getClosedTabCount, undoCloseTab // + ///////////////////////////////////// + + // get closed tab count + let count = ss.getClosedTabCount(window); + let max_tabs_undo = gPrefService.getIntPref("browser.sessionstore.max_tabs_undo"); + ok(0 <= count && count <= max_tabs_undo, + "getClosedTabCount returns zero or at most max_tabs_undo"); + + // create a new tab + let testURL = "about:"; + tab = gBrowser.addTab(testURL); + yield promiseBrowserLoaded(tab.linkedBrowser); + + // make sure that the next closed tab will increase getClosedTabCount + gPrefService.setIntPref("browser.sessionstore.max_tabs_undo", max_tabs_undo + 1); + registerCleanupFunction(() => gPrefService.clearUserPref("browser.sessionstore.max_tabs_undo")); + + // remove tab + yield promiseRemoveTab(tab); + + // getClosedTabCount + let newcount = ss.getClosedTabCount(window); + ok(newcount > count, "after closing a tab, getClosedTabCount has been incremented"); + + // undoCloseTab + tab = test(() => ss.undoCloseTab(window, 0)); + ok(tab, "undoCloseTab doesn't throw") + + yield 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 000000000..bf80cd710 --- /dev/null +++ b/browser/components/sessionstore/test/browser_354894_perwindowpb.js @@ -0,0 +1,474 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * 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 it's 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. + */ + +// 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; + let e = Services.wm.getEnumerator("navigator:browser"); + while (e.hasMoreElements()) { + if (!e.getNext().closed) + ++open; + } + + let winstates = JSON.parse(ss.getBrowserState()).windows.length; + + return { open, winstates }; +} + +add_task(function* setup() { + // 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", "navigator:browser"); + }); +}); + +/** + * 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 = Task.async(function*(options, testFunction) { + yield pushPrefs(["browser.startup.page", 3], + ["browser.tabs.warnOnClose", false]); + + // 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, false); + } + + let private = options.private || false; + let newWin = yield promiseNewWindowLoaded({ private }); + + injectTestTabs(newWin); + + yield 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); + } + + yield popPrefs(); +}); + +/** + * Loads a TEST_URLS into a browser window. + * + * @param win (Window) + * The browser window to load the tabs in + */ +function injectTestTabs(win) { + TEST_URLS.forEach(function (url) { + win.gBrowser.addTab(url); + }); +} + +/** + * 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(function* test_open_close_normal() { + if (IS_MAC) { + return; + } + + yield setupTest({ denyFirst: true }, function*(newWin, obs) { + let closed = yield closeWindowForRestoration(newWin); + ok(!closed, "First close request should have been denied"); + + closed = yield closeWindowForRestoration(newWin); + ok(closed, "Second close request should be accepted"); + + newWin = yield 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. + yield 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(function* test_open_close_private_browsing() { + if (IS_MAC) { + return; + } + + yield setupTest({}, function*(newWin, obs) { + let closed = yield closeWindowForRestoration(newWin); + ok(closed, "Should be able to close the window"); + + newWin = yield promiseNewWindowLoaded({private: true}); + is(newWin.gBrowser.browsers.length, 1, + "Did not restore in private browing mode"); + + closed = yield closeWindowForRestoration(newWin); + ok(closed, "Should be able to close the window"); + + newWin = yield 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. + yield 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 windows to check those aren't restored, but the browser + * window is. + * + * @note: Non-Mac only + * + * Should do the following: + * 1. Open a new browser window + * 2. Add some tabs + * 3. Open some popups + * 4. Add another tab to one popup (so that it gets stored) and close it again + * 5. Close the browser window + * 6. Open another browser window + * 7. Make sure that the tabs of the closed browser window, but not the popup, + * are restored + */ +add_task(function* test_open_close_window_and_popup() { + if (IS_MAC) { + return; + } + + yield setupTest({}, function*(newWin, obs) { + let popupPromise = BrowserTestUtils.waitForNewWindow(); + openDialog(location, "popup", POPUP_FEATURES, TEST_URLS[0]); + let popup = yield popupPromise; + + let popup2Promise = BrowserTestUtils.waitForNewWindow(); + openDialog(location, "popup2", POPUP_FEATURES, TEST_URLS[1]); + let popup2 = yield popup2Promise; + + popup2.gBrowser.addTab(TEST_URLS[0]); + + let closed = yield closeWindowForRestoration(newWin); + ok(closed, "Should be able to close the window"); + + yield BrowserTestUtils.closeWindow(popup2); + + newWin = yield promiseNewWindowLoaded(); + + is(newWin.gBrowser.browsers.length, TEST_URLS.length + 2, + "Restored window and associated tabs in session"); + + yield BrowserTestUtils.closeWindow(popup); + yield BrowserTestUtils.closeWindow(newWin); + + // We closed one window with closeWindowForRestoration, and it should + // have been successful. + is(obs["browser-lastwindow-close-requested"], 1, + "Got expected browser-lastwindow-close-requested notifications"); + is(obs["browser-lastwindow-close-granted"], 1, + "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(function* test_open_close_only_popup() { + if (IS_MAC) { + return; + } + + yield setupTest({}, function*(newWin, obs) { + // We actually don't care about the initial window in this test. + yield 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 = yield popupPromise; + + is(popup.gBrowser.browsers.length, 1, + "Did not restore the popup window (1)"); + + let closed = yield closeWindowForRestoration(popup); + ok(closed, "Should be able to close the window"); + + popupPromise = BrowserTestUtils.waitForNewWindow(); + openDialog(location, "popup", POPUP_FEATURES, TEST_URLS[1]); + popup = yield popupPromise; + + popup.gBrowser.addTab(TEST_URLS[0]); + is(popup.gBrowser.browsers.length, 2, + "Did not restore to the popup window (2)"); + + yield BrowserTestUtils.closeWindow(popup); + + newWin = yield 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)"); + yield 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(function* test_open_close_restore_from_popup() { + if (IS_MAC) { + return; + } + + yield setupTest({}, function*(newWin, obs) { + let newWin2 = yield promiseNewWindowLoaded(); + yield injectTestTabs(newWin2); + + let closed = yield closeWindowForRestoration(newWin); + ok(closed, "Should be able to close the window"); + closed = yield 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); + yield BrowserTestUtils.waitForEvent(newWin, "load"); + + // Make sure we wait until this window is restored. + yield BrowserTestUtils.waitForEvent(newWin.gBrowser.tabContainer, + "SSTabRestored"); + + newWin2 = yield promiseNewWindowLoaded(); + + is(newWin2.gBrowser.browsers.length, 1, + "Did not restore, as undoCloseWindow() was last called"); + 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"); + + yield BrowserTestUtils.closeWindow(newWin); + yield 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(function* test_mac_notifications() { + if (!IS_MAC) { + return; + } + + yield setupTest({ denyFirst: true }, function*(newWin, obs) { + let closed = yield closeWindowForRestoration(newWin); + ok(!closed, "First close attempt should be denied"); + closed = yield 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 000000000..3cc89a66c --- /dev/null +++ b/browser/components/sessionstore/test/browser_367052.js @@ -0,0 +1,41 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +add_task(function* () { + // make sure that the next closed tab will increase getClosedTabCount + let max_tabs_undo = gPrefService.getIntPref("browser.sessionstore.max_tabs_undo"); + gPrefService.setIntPref("browser.sessionstore.max_tabs_undo", max_tabs_undo + 1); + registerCleanupFunction(() => gPrefService.clearUserPref("browser.sessionstore.max_tabs_undo")); + + // Empty the list of closed tabs. + while (ss.getClosedTabCount(window)) { + ss.forgetClosedTab(window, 0); + } + + // restore a blank tab + let tab = gBrowser.addTab("about:"); + yield promiseBrowserLoaded(tab.linkedBrowser); + + let count = yield promiseSHistoryCount(tab.linkedBrowser); + ok(count >= 1, "the new tab does have at least one history entry"); + + yield promiseTabState(tab, {entries: []}); + + // We may have a different sessionHistory object if the tab + // switched from non-remote to remote. + count = yield promiseSHistoryCount(tab.linkedBrowser); + is(count, 0, "the tab was restored without any history whatsoever"); + + yield promiseRemoveTab(tab); + is(ss.getClosedTabCount(window), 0, + "The closed blank tab wasn't added to Recently Closed Tabs"); +}); + +function promiseSHistoryCount(browser) { + return ContentTask.spawn(browser, null, 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 000000000..c59bdcc8b --- /dev/null +++ b/browser/components/sessionstore/test/browser_393716.js @@ -0,0 +1,71 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const URL = "about:config"; + +/** + * Bug 393716 - Basic tests for getTabState(), setTabState(), and duplicateTab(). + */ +add_task(function test_set_tabstate() { + let key = "Unique key: " + Date.now(); + let value = "Unique value: " + Math.random(); + + // create a new tab + let tab = gBrowser.addTab(URL); + ss.setTabValue(tab, key, value); + yield promiseBrowserLoaded(tab.linkedBrowser); + + // get the tab's state + yield 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 > 0, + "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(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 }], extData: { key2: value2 } }; + + // create a new tab + let tab = gBrowser.addTab(); + // set the tab's state + ss.setTabState(tab, JSON.stringify(state)); + yield promiseBrowserLoaded(tab.linkedBrowser); + + // verify the correctness of the restored tab + ok(ss.getTabValue(tab, key2) == value2 && tab.linkedBrowser.currentURI.spec == URL, + "the tab's state was correctly restored"); + + // add text data + yield setInputValue(tab.linkedBrowser, {id: "textbox", value: value3}); + + // duplicate the tab + let tab2 = ss.duplicateTab(window, tab); + yield promiseTabRestored(tab2); + + // verify the correctness of the duplicated tab + ok(ss.getTabValue(tab2, key2) == value2 && + tab2.linkedBrowser.currentURI.spec == URL, + "correctly duplicated the tab's state"); + let textbox = yield getInputValue(tab2.linkedBrowser, {id: "textbox"}); + 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 000000000..1b1650e27 --- /dev/null +++ b/browser/components/sessionstore/test/browser_394759_basic.js @@ -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/. */ + +"use strict"; + +const TEST_URL = "data:text/html;charset=utf-8," + + ""; + +Cu.import("resource:///modules/sessionstore/SessionStore.jsm"); + +/** + * 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) { + newWin.gBrowser.addTab().linkedBrowser.stop(); + + // Mark the window with some unique data to be restored later on. + ss.setWindowValue(newWin, uniqueKey, uniqueValue); + let [txt, chk] = newWin.content.document.querySelectorAll("#txt, #chk"); + txt.value = uniqueText; + + let browser = newWin.gBrowser.selectedBrowser; + setInputChecked(browser, {id: "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(false); + + // 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 instanceof ChromeWindow, + "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 [txt, chk] = newWin2.content.document.querySelectorAll("#txt, #chk"); + ok(txt.value == uniqueText && chk.checked, + "The window correctly restored the form"); + is(ss.getWindowValue(newWin2, uniqueKey), uniqueValue, + "The window correctly restored the data associated with it"); + + // Clean up. + BrowserTestUtils.closeWindow(newWin2).then(finish); + }, true); + }); + }); + }, TEST_URL); +} + +function setInputChecked(browser, data) { + return sendMessage(browser, "ss-test:setInputChecked", data); +} 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 000000000..aa74dc061 --- /dev/null +++ b/browser/components/sessionstore/test/browser_394759_behavior.js @@ -0,0 +1,76 @@ +/** + * 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 Task.spawn(function*() { + for (let winData of windowsToOpen) { + let features = "chrome,dialog=no," + + (winData.isPopup ? "all=no" : "all"); + let url = "http://example.com/?window=" + windowsToOpen.length; + + let openWindowPromise = BrowserTestUtils.waitForNewWindow(true, url); + openDialog(getBrowserURL(), "", features, url); + let win = yield openWindowPromise; + yield BrowserTestUtils.closeWindow(win); + } + + let closedWindowData = JSON.parse(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(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}}; + + yield 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}}; + + yield testWindows(windowsToOpen2, expectedResults2); +}); \ No newline at end of file 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 000000000..83eec3070 --- /dev/null +++ b/browser/components/sessionstore/test/browser_394759_perwindowpb.js @@ -0,0 +1,55 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 Task.spawn(function*() { + let win = yield BrowserTestUtils.openNewBrowserWindow({ "private": aIsPrivate }); + win.gBrowser.selectedBrowser.loadURI(aTest.url); + yield promiseBrowserLoaded(win.gBrowser.selectedBrowser); + yield Promise.resolve(); + // Mark the window with some unique data to be restored later on. + ss.setWindowValue(win, aTest.key, aTest.value); + yield TabStateFlusher.flushWindow(win); + // Close. + yield BrowserTestUtils.closeWindow(win); + }); +} + +function promiseTestOnWindow(aIsPrivate, aValue) { + return Task.spawn(function*() { + let win = yield BrowserTestUtils.openNewBrowserWindow({ "private": aIsPrivate }); + yield TabStateFlusher.flushWindow(win); + let data = JSON.parse(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_task(function* init() { + forgetClosedWindows(); + while (ss.getClosedTabCount(window) > 0) { + ss.forgetClosedTab(window, 0); + } +}); + +add_task(function* main() { + yield promiseTestOpenCloseWindow(false, TESTS[0]); + yield promiseTestOpenCloseWindow(true, TESTS[1]); + yield promiseTestOnWindow(false, TESTS[0].value); + yield 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 000000000..75144aba1 --- /dev/null +++ b/browser/components/sessionstore/test/browser_394759_purge.js @@ -0,0 +1,130 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +Components.utils.import("resource://gre/modules/ForgetAboutSite.jsm"); + +function waitForClearHistory(aCallback) { + let observer = { + observe: function(aSubject, aTopic, aData) { + Services.obs.removeObserver(this, "browser:purge-domain-data"); + setTimeout(aCallback, 0); + } + }; + Services.obs.addObserver(observer, "browser:purge-domain-data", false); +} + +function test() { + waitForExplicitFinish(); + // 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/" }] }], selected: 1 } ], + _closedWindows : [ + // _closedWindows[0] + { + tabs: [ + { entries: [{ url: "http://example.com/", title: REMEMBER }] }, + { entries: [{ url: "http://mozilla.org/", title: FORGET }] } + ], + selected: 2, + title: "mozilla.org", + _closedTabs: [] + }, + // _closedWindows[1] + { + tabs: [ + { entries: [{ url: "http://mozilla.org/", title: FORGET }] }, + { entries: [{ url: "http://example.com/", title: REMEMBER }] }, + { entries: [{ url: "http://example.com/", title: REMEMBER }] }, + { entries: [{ url: "http://mozilla.org/", title: FORGET }] }, + { entries: [{ url: "http://example.com/", title: REMEMBER }] } + ], + selected: 5, + _closedTabs: [] + }, + // _closedWindows[2] + { + tabs: [ + { entries: [{ url: "http://example.com/", title: REMEMBER }] } + ], + selected: 1, + _closedTabs: [ + { + state: { + entries: [ + { url: "http://mozilla.org/", title: FORGET }, + { url: "http://mozilla.org/again", title: "doesn't matter" } + ] + }, + pos: 1, + title: FORGET + }, + { + state: { + entries: [ + { url: "http://example.com", title: REMEMBER } + ] + }, + title: REMEMBER + } + ] + } + ] + }; + + // set browser to test state + ss.setBrowserState(JSON.stringify(testState)); + + // purge domain & check that we purged correctly for closed windows + ForgetAboutSite.removeDataFromDomain("mozilla.org"); + waitForClearHistory(function() { + let closedWindowData = JSON.parse(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); + finish(); + }); +} diff --git a/browser/components/sessionstore/test/browser_423132.js b/browser/components/sessionstore/test/browser_423132.js new file mode 100644 index 000000000..584002cff --- /dev/null +++ b/browser/components/sessionstore/test/browser_423132.js @@ -0,0 +1,59 @@ +"use strict"; + +/** + * Tests that cookies are stored and restored correctly + * by sessionstore (bug 423132). + */ +add_task(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 + yield SpecialPowers.pushPrefEnv({ + set: [["browser.sessionstore.interval", 0]] + }); + + let win = yield BrowserTestUtils.openNewBrowserWindow(); + let browser = win.gBrowser.selectedBrowser; + browser.loadURI(testURL); + yield BrowserTestUtils.browserLoaded(browser); + + yield TabStateFlusher.flush(browser); + + // get the sessionstore state for the window + let state = ss.getWindowState(win); + + // verify our cookie got set during pageload + let enumerator = Services.cookies.enumerator; + let cookie; + let i = 0; + while (enumerator.hasMoreElements()) { + cookie = enumerator.getNext().QueryInterface(Ci.nsICookie); + i++; + } + Assert.equal(i, 1, "expected one cookie"); + + // remove the cookie + Services.cookies.removeAll(); + + // restore the window state + ss.setWindowState(win, state, true); + + // at this point, the cookie should be restored... + enumerator = Services.cookies.enumerator; + let cookie2; + while (enumerator.hasMoreElements()) { + cookie2 = enumerator.getNext().QueryInterface(Ci.nsICookie); + 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(); + yield BrowserTestUtils.closeWindow(win); +}); 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 000000000..6ff7e7aa3 --- /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 000000000..a7b6a5ee8 --- /dev/null +++ b/browser/components/sessionstore/test/browser_447951.js @@ -0,0 +1,65 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +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 + gPrefService.setIntPref("browser.sessionstore.max_serialize_back", -1); + gPrefService.setIntPref("browser.sessionstore.max_serialize_forward", -1); + registerCleanupFunction(function () { + gPrefService.clearUserPref("browser.sessionstore.max_serialize_back"); + gPrefService.clearUserPref("browser.sessionstore.max_serialize_forward"); + }); + + let tab = gBrowser.addTab(); + promiseBrowserLoaded(tab.linkedBrowser).then(() => { + let tabState = { entries: [] }; + let max_entries = gPrefService.getIntPref("browser.sessionhistory.max_entries"); + for (let i = 0; i < max_entries; i++) + tabState.entries.push({ url: baseURL + i }); + + 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) + ContentTask.spawn(tab.linkedBrowser, null, 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 000000000..00282f25e --- /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 000000000..fb8206e2f --- /dev/null +++ b/browser/components/sessionstore/test/browser_454908.js @@ -0,0 +1,47 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const URL = ROOT + "browser_454908_sample.html"; +const PASS = "pwd-" + Math.random(); + +/** + * Bug 454908 - Don't save/restore values of password fields. + */ +add_task(function* test_dont_save_passwords() { + // Make sure we do save form data. + Services.prefs.clearUserPref("browser.sessionstore.privacy_level"); + + // Add a tab with a password field. + let tab = gBrowser.addTab(URL); + let browser = tab.linkedBrowser; + yield promiseBrowserLoaded(browser); + + // Fill in some values. + let usernameValue = "User " + Math.random(); + yield setInputValue(browser, {id: "username", value: usernameValue}); + yield setInputValue(browser, {id: "passwd", value: PASS}); + + // Close and restore the tab. + yield promiseRemoveTab(tab); + tab = ss.undoCloseTab(window, 0); + browser = tab.linkedBrowser; + yield promiseTabRestored(tab); + + // Check that password fields aren't saved/restored. + let username = yield getInputValue(browser, {id: "username"}); + is(username, usernameValue, "username was saved/restored"); + let passwd = yield getInputValue(browser, {id: "passwd"}); + is(passwd, "", "password wasn't saved/restored"); + + // Write to disk and read our file. + yield forceSaveState(); + yield 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 000000000..02f40bf20 --- /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 000000000..d7ed33ee5 --- /dev/null +++ b/browser/components/sessionstore/test/browser_456342.js @@ -0,0 +1,49 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const URL = ROOT + "browser_456342_sample.xhtml"; + +/** + * Bug 456342 - Restore values from non-standard input field types. + */ +add_task(function test_restore_nonstandard_input_values() { + // Add tab with various non-standard input field types. + let tab = gBrowser.addTab(URL); + let browser = tab.linkedBrowser; + yield promiseBrowserLoaded(browser); + + // Fill in form values. + let expectedValue = Math.random(); + yield setFormElementValues(browser, {value: expectedValue}); + + // Remove tab and check collected form data. + yield promiseRemoveTab(tab); + let undoItems = JSON.parse(ss.getClosedTabData(window)); + let savedFormData = undoItems[0].state.formdata; + + let countGood = 0, countBad = 0; + for (let id of Object.keys(savedFormData.id)) { + if (savedFormData.id[id] == expectedValue) { + countGood++; + } else { + countBad++; + } + } + + for (let exp of Object.keys(savedFormData.xpath)) { + if (savedFormData.xpath[exp] == expectedValue) { + countGood++; + } else { + countBad++; + } + } + + is(countGood, 4, "Saved text for non-standard input fields"); + is(countBad, 0, "Didn't save text for ignored field types"); +}); + +function setFormElementValues(browser, data) { + return sendMessage(browser, "ss-test:setFormElementValues", data); +} 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 000000000..a08777a8d --- /dev/null +++ b/browser/components/sessionstore/test/browser_456342_sample.xhtml @@ -0,0 +1,36 @@ + + + +Test for bug 456342 + + +
+

Non-standard <input>s

+

Search

+

Image Search:

+

Autocomplete:

+

Mistyped:

+ +

Ignored types

+ + + + + + + + + + + + + + +

Textarea with unchanged text

+ + + +

file field with changed value

+ + +

file field with unchanged value

+ + + + +

Select menu with changed selection

+ + +

Select menu with unchanged selection (change event still fires)

+ + +

Multiple Select menu with changed selection

+ + +

Select menu with unchanged selection

+ + +

checkbox with changed value

+ + + +

checkbox with unchanged value

+ + + +

radio with changed value

+Radio 1 +Radio 2 +Radio 3 + +

radio with unchanged value

+Radio 4 +Radio 5 +Radio 6 + +

Changed field IDs

+
+
+
diff --git a/browser/components/sessionstore/test/browser_formdata.js b/browser/components/sessionstore/test/browser_formdata.js new file mode 100644 index 000000000..ce1272888 --- /dev/null +++ b/browser/components/sessionstore/test/browser_formdata.js @@ -0,0 +1,194 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +requestLongerTimeout(2); + +/** + * This test ensures that form data collection respects the privacy level as + * set by the user. + */ +add_task(function test_formdata() { + const URL = "http://mochi.test:8888/browser/browser/components/" + + "sessionstore/test/browser_formdata_sample.html"; + + const OUTER_VALUE = "browser_formdata_" + Math.random(); + const INNER_VALUE = "browser_formdata_" + Math.random(); + + // Creates a tab, loads a page with some form fields, + // modifies their values and closes the tab. + function createAndRemoveTab() { + return Task.spawn(function () { + // Create a new tab. + let tab = gBrowser.addTab(URL); + let browser = tab.linkedBrowser; + yield promiseBrowserLoaded(browser); + + // Modify form data. + yield setInputValue(browser, {id: "txt", value: OUTER_VALUE}); + yield setInputValue(browser, {id: "txt", value: INNER_VALUE, frame: 0}); + + // Remove the tab. + yield promiseRemoveTab(tab); + }); + } + + yield createAndRemoveTab(); + let [{state: {formdata}}] = JSON.parse(ss.getClosedTabData(window)); + is(formdata.id.txt, OUTER_VALUE, "outer value is correct"); + is(formdata.children[0].id.txt, INNER_VALUE, "inner value is correct"); + + // Disable saving data for encrypted sites. + Services.prefs.setIntPref("browser.sessionstore.privacy_level", 1); + + yield createAndRemoveTab(); + [{state: {formdata}}] = JSON.parse(ss.getClosedTabData(window)); + is(formdata.id.txt, OUTER_VALUE, "outer value is correct"); + ok(!formdata.children, "inner value was *not* stored"); + + // Disable saving data for any site. + Services.prefs.setIntPref("browser.sessionstore.privacy_level", 2); + + yield createAndRemoveTab(); + [{state: {formdata}}] = JSON.parse(ss.getClosedTabData(window)); + ok(!formdata, "form data has *not* been stored"); + + // Restore the default privacy level. + Services.prefs.clearUserPref("browser.sessionstore.privacy_level"); +}); + +/** + * This test ensures that a malicious website can't trick us into restoring + * form data into a wrong website and that we always check the stored URL + * before doing so. + */ +add_task(function test_url_check() { + const URL = "data:text/html;charset=utf-8,"; + const VALUE = "value-" + Math.random(); + + // Create a tab with an iframe containing an input field. + let tab = gBrowser.addTab(URL); + let browser = tab.linkedBrowser; + yield promiseBrowserLoaded(browser); + + // Restore a tab state with a given form data url. + function restoreStateWithURL(url) { + let state = {entries: [{url: URL}], formdata: {id: {input: VALUE}}}; + + if (url) { + state.formdata.url = url; + } + + return promiseTabState(tab, state).then(() => getInputValue(browser, "input")); + } + + // Check that the form value is restored with the correct URL. + is((yield restoreStateWithURL(URL)), VALUE, "form data restored"); + + // Check that the form value is *not* restored with the wrong URL. + is((yield restoreStateWithURL(URL + "?")), "", "form data not restored"); + is((yield restoreStateWithURL()), "", "form data not restored"); + + // Cleanup. + gBrowser.removeTab(tab); +}); + +/** + * This test ensures that collecting form data works as expected when having + * nested frame sets. + */ +add_task(function test_nested() { + const URL = "data:text/html;charset=utf-8," + + "" + + "clickme" + + "clickme"; + + // Create a new tab. + let tab = gBrowser.addTab(URL); + let browser = tab.linkedBrowser; + yield promiseBrowserLoaded(browser); + + // Check that we have a single shistory entry. + yield 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. + browser.messageManager.sendAsyncMessage("ss-test:click", {id: "a1"}); + yield promiseBrowserLoaded(browser, false /* don't ignore subframes */); + + // Check shistory. + yield 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. + browser.goBack(); + yield promiseBrowserLoaded(browser, false /* don't ignore subframes */); + + // Navigate the subframe again. + browser.messageManager.sendAsyncMessage("ss-test:click", {id: "a2"}); + yield promiseContentMessage(browser, "ss-test:hashchange"); + + // Check shistory. + yield 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(function test_about_page_navigate() { + // Create a new tab. + let tab = gBrowser.addTab("about:blank"); + let browser = tab.linkedBrowser; + yield promiseBrowserLoaded(browser); + + // Check that we have a single shistory entry. + yield 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"); + + browser.loadURI("about:robots"); + yield promiseBrowserLoaded(browser); + + // Check that we have changed the history entry. + yield 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(function test_pushstate_replacestate() { + // Create a new tab. + let tab = gBrowser.addTab("http://example.com/1"); + let browser = tab.linkedBrowser; + yield promiseBrowserLoaded(browser); + + // Check that we have a single shistory entry. + yield 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"); + + yield ContentTask.spawn(browser, {}, function* () { + content.window.history.pushState({}, "", 'test-entry/'); + }); + + // Check that we have added the history entry. + yield 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"); + + yield ContentTask.spawn(browser, {}, function* () { + content.window.history.replaceState({}, "", "test-entry2/"); + }); + + // Check that we have modified the history entry. + yield 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(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 = gBrowser.addTab(URL); + let browser = tab.linkedBrowser; + yield promiseBrowserLoaded(browser); + + yield 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); +}); 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 000000000..41da3c2ad --- /dev/null +++ b/browser/components/sessionstore/test/browser_sessionHistory_slow.sjs @@ -0,0 +1,21 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const Cc = Components.classes; +const Ci = Components.interfaces; + +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 000000000..7e2dccf4a --- /dev/null +++ b/browser/components/sessionstore/test/browser_sessionStorage.html @@ -0,0 +1,27 @@ + + + + + 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 000000000..b580c5cc2 --- /dev/null +++ b/browser/components/sessionstore/test/browser_sessionStorage.js @@ -0,0 +1,188 @@ +/* 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; +const INNER_VALUE = "inner-value-" + RAND; + +/** + * This test ensures that setting, modifying and restoring sessionStorage data + * works as expected. + */ +add_task(function session_storage() { + let tab = gBrowser.addTab(URL); + let browser = tab.linkedBrowser; + yield promiseBrowserLoaded(browser); + + // Flush to make sure chrome received all data. + yield TabStateFlusher.flush(browser); + + let {storage} = JSON.parse(ss.getTabState(tab)); + is(storage["http://example.com"].test, INNER_VALUE, + "sessionStorage data for example.com has been serialized correctly"); + is(storage["http://mochi.test:8888"].test, OUTER_VALUE, + "sessionStorage data for mochi.test has been serialized correctly"); + + // Ensure that modifying sessionStore values works for the inner frame only. + yield modifySessionStorage(browser, {test: "modified1"}, {frameIndex: 0}); + yield TabStateFlusher.flush(browser); + + ({storage} = JSON.parse(ss.getTabState(tab))); + is(storage["http://example.com"].test, "modified1", + "sessionStorage data for example.com has been serialized correctly"); + is(storage["http://mochi.test:8888"].test, OUTER_VALUE, + "sessionStorage data for mochi.test has been serialized correctly"); + + // Ensure that modifying sessionStore values works for both frames. + yield modifySessionStorage(browser, {test: "modified"}); + yield modifySessionStorage(browser, {test: "modified2"}, {frameIndex: 0}); + yield TabStateFlusher.flush(browser); + + ({storage} = JSON.parse(ss.getTabState(tab))); + is(storage["http://example.com"].test, "modified2", + "sessionStorage data for example.com has been serialized correctly"); + is(storage["http://mochi.test:8888"].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; + yield promiseTabRestored(tab2); + + // Flush to make sure chrome received all data. + yield TabStateFlusher.flush(browser2); + + ({storage} = JSON.parse(ss.getTabState(tab2))); + is(storage["http://example.com"].test, "modified2", + "sessionStorage data for example.com has been duplicated correctly"); + is(storage["http://mochi.test:8888"].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. + yield modifySessionStorage(browser2, {test: "modified3"}); + yield TabStateFlusher.flush(browser2); + + ({storage} = JSON.parse(ss.getTabState(tab2))); + is(storage["http://example.com"].test, "modified2", + "sessionStorage data for example.com has been duplicated correctly"); + is(storage["http://mochi.test:8888"].test, "modified3", + "sessionStorage data for mochi.test has been duplicated correctly"); + + // Check that loading a new URL discards data. + browser2.loadURI("http://mochi.test:8888/"); + yield promiseBrowserLoaded(browser2); + yield TabStateFlusher.flush(browser2); + + ({storage} = JSON.parse(ss.getTabState(tab2))); + is(storage["http://mochi.test:8888"].test, "modified3", + "navigating retains correct storage data"); + ok(!storage["http://example.com"], "storage data was discarded"); + + // Check that loading a new URL discards data. + browser2.loadURI("about:mozilla"); + yield promiseBrowserLoaded(browser2); + yield TabStateFlusher.flush(browser2); + + let state = JSON.parse(ss.getTabState(tab2)); + ok(!state.hasOwnProperty("storage"), "storage data was discarded"); + + // Clean up. + yield promiseRemoveTab(tab); + yield promiseRemoveTab(tab2); +}); + +/** + * This test ensures that purging domain data also purges data from the + * sessionStorage data collected for tabs. + */ +add_task(function purge_domain() { + let tab = gBrowser.addTab(URL); + let browser = tab.linkedBrowser; + yield promiseBrowserLoaded(browser); + + // Purge data for "mochi.test". + yield purgeDomainData(browser, "mochi.test"); + + // Flush to make sure chrome received all data. + yield TabStateFlusher.flush(browser); + + let {storage} = JSON.parse(ss.getTabState(tab)); + ok(!storage["http://mochi.test:8888"], + "sessionStorage data for mochi.test has been purged"); + is(storage["http://example.com"].test, INNER_VALUE, + "sessionStorage data for example.com has been preserved"); + + yield promiseRemoveTab(tab); +}); + +/** + * This test ensures that collecting sessionStorage data respects the privacy + * levels as set by the user. + */ +add_task(function respect_privacy_level() { + let tab = gBrowser.addTab(URL + "&secure"); + yield promiseBrowserLoaded(tab.linkedBrowser); + yield promiseRemoveTab(tab); + + let [{state: {storage}}] = JSON.parse(ss.getClosedTabData(window)); + is(storage["http://mochi.test:8888"].test, OUTER_VALUE, + "http sessionStorage data has been saved"); + is(storage["https://example.com"].test, INNER_VALUE, + "https sessionStorage data has been saved"); + + // Disable saving data for encrypted sites. + Services.prefs.setIntPref("browser.sessionstore.privacy_level", 1); + + tab = gBrowser.addTab(URL + "&secure"); + yield promiseBrowserLoaded(tab.linkedBrowser); + yield promiseRemoveTab(tab); + + [{state: {storage}}] = JSON.parse(ss.getClosedTabData(window)); + is(storage["http://mochi.test:8888"].test, OUTER_VALUE, + "http sessionStorage data has been saved"); + ok(!storage["https://example.com"], + "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 = gBrowser.addTab(URL + "&secure"); + yield promiseBrowserLoaded(tab.linkedBrowser); + let tab2 = gBrowser.duplicateTab(tab); + yield promiseTabRestored(tab2); + yield promiseRemoveTab(tab); + + // With privacy_level=2 the |tab| shouldn't have any sessionStorage data. + [{state: {storage}}] = JSON.parse(ss.getClosedTabData(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. + while (ss.getClosedTabCount(window) > 0) { + ss.forgetClosedTab(window, 0); + } + + // Restore the default privacy level and close the duplicated tab. + Services.prefs.clearUserPref("browser.sessionstore.privacy_level"); + yield promiseRemoveTab(tab2); + + // With privacy_level=0 the duplicated |tab2| should persist all data. + [{state: {storage}}] = JSON.parse(ss.getClosedTabData(window)); + is(storage["http://mochi.test:8888"].test, OUTER_VALUE, + "http sessionStorage data has been saved"); + is(storage["https://example.com"].test, INNER_VALUE, + "https sessionStorage data has been saved"); +}); + +function purgeDomainData(browser, domain) { + return sendMessage(browser, "ss-test:purgeDomainData", domain); +} 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 000000000..d1d894611 --- /dev/null +++ b/browser/components/sessionstore/test/browser_sessionStorage_size.js @@ -0,0 +1,51 @@ +/* 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; + +// Test that we record the size of messages. +add_task(function* test_telemetry() { + Services.telemetry.canRecordExtended = true; + let histogram = Services.telemetry.getHistogramById("FX_SESSION_RESTORE_DOM_STORAGE_SIZE_ESTIMATE_CHARS"); + let snap1 = histogram.snapshot(); + + let tab = gBrowser.addTab(URL); + let browser = tab.linkedBrowser; + yield promiseBrowserLoaded(browser); + + // Flush to make sure chrome received all data. + yield TabStateFlusher.flush(browser); + let snap2 = histogram.snapshot(); + + Assert.ok(snap2.counts[5] > snap1.counts[5]); + yield promiseRemoveTab(tab); + Services.telemetry.canRecordExtended = false; +}); + +// Lower the size limit for DOM Storage content. Check that DOM Storage +// is not updated, but that other things remain updated. +add_task(function* test_large_content() { + Services.prefs.setIntPref("browser.sessionstore.dom_storage_limit", 5); + + let tab = gBrowser.addTab(URL); + let browser = tab.linkedBrowser; + yield promiseBrowserLoaded(browser); + + // Flush to make sure chrome received all data. + yield 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); + yield promiseRemoveTab(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 000000000..1bc9537e2 --- /dev/null +++ b/browser/components/sessionstore/test/browser_sessionStoreContainer.js @@ -0,0 +1,141 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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(function* () { + for (let i = 0; i < 3; ++i) { + let tab = gBrowser.addTab("http://example.com/", { userContextId: i }); + let browser = tab.linkedBrowser; + + yield promiseBrowserLoaded(browser); + + let tab2 = gBrowser.duplicateTab(tab); + Assert.equal(tab2.getAttribute("usercontextid"), i); + let browser2 = tab2.linkedBrowser; + yield promiseTabRestored(tab2) + + yield ContentTask.spawn(browser2, { expectedId: i }, function* (args) { + let loadContext = docShell.QueryInterface(Ci.nsILoadContext); + Assert.equal(loadContext.originAttributes.userContextId, + args.expectedId, "The docShell has the correct userContextId"); + }); + + yield promiseRemoveTab(tab); + yield promiseRemoveTab(tab2); + } +}); + +add_task(function* () { + let tab = gBrowser.addTab("http://example.com/", { userContextId: 1 }); + let browser = tab.linkedBrowser; + + yield promiseBrowserLoaded(browser); + + gBrowser.selectedTab = tab; + + let tab2 = gBrowser.duplicateTab(tab); + let browser2 = tab2.linkedBrowser; + yield promiseTabRestored(tab2) + + yield ContentTask.spawn(browser2, { expectedId: 1 }, function* (args) { + Assert.equal(docShell.getOriginAttributes().userContextId, + args.expectedId, + "The docShell has the correct userContextId"); + }); + + yield promiseRemoveTab(tab); + yield promiseRemoveTab(tab2); +}); + +add_task(function* () { + let tab = gBrowser.addTab("http://example.com/", { userContextId: 1 }); + let browser = tab.linkedBrowser; + + yield promiseBrowserLoaded(browser); + + gBrowser.removeTab(tab); + + let tab2 = ss.undoCloseTab(window, 0); + Assert.equal(tab2.getAttribute("usercontextid"), 1); + yield promiseTabRestored(tab2); + yield ContentTask.spawn(tab2.linkedBrowser, { expectedId: 1 }, function* (args) { + Assert.equal(docShell.getOriginAttributes().userContextId, + args.expectedId, + "The docShell has the correct userContextId"); + }); + + yield promiseRemoveTab(tab2); +}); + +// Opens "uri" in a new tab with the provided userContextId and focuses it. +// Returns the newly opened tab. +function* openTabInUserContext(userContextId) { + // Open the tab in the correct userContextId. + let tab = gBrowser.addTab("http://example.com", { userContextId }); + + // Select tab and make sure its browser is focused. + gBrowser.selectedTab = tab; + tab.ownerGlobal.focus(); + + let browser = gBrowser.getBrowserForTab(tab); + yield BrowserTestUtils.browserLoaded(browser); + return { tab, browser }; +} + +function waitForNewCookie() { + return new Promise(resolve => { + Services.obs.addObserver(function observer(subj, topic, data) { + let cookie = subj.QueryInterface(Ci.nsICookie2); + if (data == "added") { + Services.obs.removeObserver(observer, topic); + resolve(); + } + }, "cookie-changed", false); + }); +} + +add_task(function* test() { + const USER_CONTEXTS = [ + "default", + "personal", + "work", + ]; + + const ss = Cc["@mozilla.org/browser/sessionstore;1"].getService(Ci.nsISessionStore); + const { TabStateFlusher } = Cu.import("resource:///modules/sessionstore/TabStateFlusher.jsm", {}); + + // Make sure userContext is enabled. + yield SpecialPowers.pushPrefEnv({ + "set": [ [ "privacy.userContext.enabled", true ] ] + }); + + let lastSessionRestore; + 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 } = yield* openTabInUserContext(userContextId); + + yield Promise.all([ + waitForNewCookie(), + ContentTask.spawn(browser, cookie, cookie => content.document.cookie = cookie) + ]); + + // Ensure the tab's session history is up-to-date. + yield TabStateFlusher.flush(browser); + + lastSessionRestore = ss.getWindowState(window); + + // Remove the tab. + gBrowser.removeTab(tab); + } + + let state = JSON.parse(lastSessionRestore); + is(state.windows[0].cookies.length, USER_CONTEXTS.length, + "session restore should have each container's cookie"); +}); + diff --git a/browser/components/sessionstore/test/browser_swapDocShells.js b/browser/components/sessionstore/test/browser_swapDocShells.js new file mode 100644 index 000000000..839f060e7 --- /dev/null +++ b/browser/components/sessionstore/test/browser_swapDocShells.js @@ -0,0 +1,35 @@ +"use strict"; + +add_task(function* () { + let tab = gBrowser.selectedTab = gBrowser.addTab("about:mozilla"); + yield promiseBrowserLoaded(gBrowser.selectedBrowser); + + let win = gBrowser.replaceTabWithWindow(tab); + yield promiseDelayedStartupFinished(win); + yield promiseBrowserHasURL(win.gBrowser.browsers[0], "about:mozilla"); + + win.duplicateTabIn(win.gBrowser.selectedTab, "tab"); + yield promiseTabRestored(win.gBrowser.tabs[1]); + + let browser = win.gBrowser.browsers[1]; + is(browser.currentURI.spec, "about:mozilla", "tab was duplicated"); + + yield BrowserTestUtils.closeWindow(win); +}); + +function promiseDelayedStartupFinished(win) { + let deferred = Promise.defer(); + whenDelayedStartupFinished(win, deferred.resolve); + return deferred.promise; +} + +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 000000000..9eb8c260a --- /dev/null +++ b/browser/components/sessionstore/test/browser_switch_remoteness.js @@ -0,0 +1,49 @@ +"use strict"; + +const URL = "http://example.com/browser_switch_remoteness_"; + +function countHistoryEntries(browser, expected) { + return ContentTask.spawn(browser, { expected }, function* (args) { + let Ci = Components.interfaces; + let webNavigation = docShell.QueryInterface(Ci.nsIWebNavigation); + let history = webNavigation.sessionHistory.QueryInterface(Ci.nsISHistoryInternal); + Assert.equal(history && history.count, args.expected, + "correct number of shistory entries"); + }); +} + +add_task(function* () { + // Open a new window. + let win = yield promiseNewWindowLoaded(); + + // Add a new tab. + let tab = win.gBrowser.addTab("about:blank"); + let browser = tab.linkedBrowser; + yield 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++) { + browser.loadURI(URL + i); + yield promiseBrowserLoaded(browser); + ok(browser.isRemoteBrowser, "browser is still remote"); + } + + // Check we have the right number of shistory entries. + yield countHistoryEntries(browser, MAX_BACK + 2); + + // Load a non-remote page. + browser.loadURI("about:robots"); + yield promiseTabRestored(tab); + ok(!browser.isRemoteBrowser, "browser is not remote anymore"); + + // Check that we didn't lose any shistory entries. + yield countHistoryEntries(browser, MAX_BACK + 3); + + // Cleanup. + yield BrowserTestUtils.closeWindow(win); +}); diff --git a/browser/components/sessionstore/test/browser_undoCloseById.js b/browser/components/sessionstore/test/browser_undoCloseById.js new file mode 100644 index 000000000..f2f0f919c --- /dev/null +++ b/browser/components/sessionstore/test/browser_undoCloseById.js @@ -0,0 +1,118 @@ +"use strict"; + +/** + * This test is for the undoCloseById function. + */ + +Cu.import("resource:///modules/sessionstore/SessionStore.jsm"); + +function openAndCloseTab(window, url) { + let tab = window.gBrowser.addTab(url); + yield promiseBrowserLoaded(tab.linkedBrowser, true, url); + yield TabStateFlusher.flush(tab.linkedBrowser); + yield promiseRemoveTab(tab); +} + +function* openWindow(url) { + let win = yield promiseNewWindowLoaded(); + let flags = Ci.nsIWebNavigation.LOAD_FLAGS_REPLACE_HISTORY; + win.gBrowser.selectedBrowser.loadURIWithFlags(url, flags); + yield promiseBrowserLoaded(win.gBrowser.selectedBrowser, true, url); + return win; +} + +function closeWindow(win) { + yield BrowserTestUtils.closeWindow(win); + // Wait 20 ms to allow SessionStorage a chance to register the closed window. + yield new Promise(resolve => setTimeout(resolve, 20)); +} + +add_task(function* test_undoCloseById() { + // Clear the lists of closed windows and tabs. + forgetClosedWindows(); + while (SessionStore.getClosedTabCount(window)) { + SessionStore.forgetClosedTab(window, 0); + } + + // Open a new window. + let win = yield openWindow("about:robots"); + + // Open and close a tab. + yield openAndCloseTab(win, "about:mozilla"); + is(SessionStore.lastClosedObjectType, "tab", "The last closed object is a tab"); + + // Record the first closedId created. + let initialClosedId = SessionStore.getClosedTabData(win, false)[0].closedId; + + // Open and close another window. + let win2 = yield openWindow("about:mozilla"); + yield 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. + yield 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); + yield 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); + yield promiseBrowserLoaded(tab2.linkedBrowser); + is(tab2.linkedBrowser.currentURI.spec, "about:mozilla", "The expected tab was re-opened"); + + // Close the two tabs we re-opened. + yield promiseRemoveTab(tab); // closedId == initialClosedId + 3 + is(SessionStore.lastClosedObjectType, "tab", "The last closed object is a tab"); + yield promiseRemoveTab(tab2); // closedId == initialClosedId + 4 + is(SessionStore.lastClosedObjectType, "tab", "The last closed object is a tab"); + + // Open another new window. + let win3 = yield openWindow("about:mozilla"); + + // Close both windows. + yield closeWindow(win); // closedId == initialClosedId + 5 + is(SessionStore.lastClosedObjectType, "window", "The last closed object is a window"); + yield 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); + yield BrowserTestUtils.waitForEvent(win, "load"); + + // Make sure we wait until this window is restored. + yield 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. + yield closeWindow(win); + is(SessionStore.lastClosedObjectType, "window", "The last closed object is a window"); + + // Undo closing the first window. + win = SessionStore.undoCloseById(initialClosedId + 5); + + yield BrowserTestUtils.waitForEvent(win, "load"); + + // Make sure we wait until this window is restored. + yield BrowserTestUtils.waitForEvent(win.gBrowser.tabContainer, + "SSTabRestored"); + + is(win.gBrowser.selectedBrowser.currentURI.spec, "about:robots", "The expected window was re-opened"); + + // Close the window again. + yield 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 000000000..e46348e59 --- /dev/null +++ b/browser/components/sessionstore/test/browser_unrestored_crashedTabs.js @@ -0,0 +1,69 @@ +/* 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(function* test() { + yield pushPrefs([PREF, true]); + + yield BrowserTestUtils.withNewTab({ + gBrowser, + url: PAGE, + }, function*(browser) { + yield 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 = gBrowser.addTab("about:blank", { + skipAnimation: true, + forceNotRemote: true, + }); + + let state = { + entries: [{url: PAGE}], + }; + + 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. + yield BrowserTestUtils.crashBrowser(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; + yield 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); + yield promiseTabRestored(originalTab); + + // Clean up. + yield 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 000000000..768671051 --- /dev/null +++ b/browser/components/sessionstore/test/browser_upgrade_backup.js @@ -0,0 +1,134 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://gre/modules/Services.jsm", this); +Cu.import("resource://gre/modules/osfile.jsm", this); +Cu.import("resource://gre/modules/Task.jsm", this); +Cu.import("resource://gre/modules/Preferences.jsm", this); + +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. + */ +var prepareTest = Task.async(function* () { + let result = {}; + + result.buildID = Services.appinfo.platformBuildID; + Services.prefs.setCharPref(PREF_UPGRADE, ""); + result.contents = JSON.stringify({"browser_upgrade_backup.js": Math.random()}); + + return result; +}); + +/** + * Retrieves all upgrade backups and returns them in an array. + */ +var getUpgradeBackups = Task.async(function* () { + let iterator; + let backups = []; + let upgradeBackupPrefix = Paths.upgradeBackupPrefix; + + try { + iterator = new OS.File.DirectoryIterator(Paths.backups); + + // iterate over all files in the backup directory + yield iterator.forEach(function (file) { + // check the upgradeBackupPrefix + if (file.path.startsWith(Paths.upgradeBackupPrefix)) { + // the file is a backup + backups.push(file.path); + } + }, this); + } finally { + if (iterator) { + iterator.close(); + } + } + + // return results + return backups; +}); + +add_task(function* init() { + // Wait until initialization is complete + yield SessionStore.promiseInitialized; + yield SessionFile.wipe(); +}); + +add_task(function* test_upgrade_backup() { + let test = yield prepareTest(); + info("Let's check if we create an upgrade backup"); + yield OS.File.writeAtomic(Paths.clean, test.contents); + yield SessionFile.read(); // First call to read() initializes the SessionWorker + yield SessionFile.write(""); // First call to write() triggers the backup + + is(Services.prefs.getCharPref(PREF_UPGRADE), test.buildID, "upgrade backup should be set"); + + is((yield OS.File.exists(Paths.upgradeBackup)), true, "upgrade backup file has been created"); + + let data = yield OS.File.read(Paths.upgradeBackup); + is(test.contents, (new TextDecoder()).decode(data), "upgrade backup contains the expected contents"); + + info("Let's check that we don't overwrite this upgrade backup"); + let newContents = JSON.stringify({"something else entirely": Math.random()}); + yield OS.File.writeAtomic(Paths.clean, newContents); + yield SessionFile.read(); // Reinitialize the SessionWorker + yield SessionFile.write(""); // Next call to write() shouldn't trigger the backup + data = yield OS.File.read(Paths.upgradeBackup); + is(test.contents, (new TextDecoder()).decode(data), "upgrade backup hasn't changed"); +}); + +add_task(function* test_upgrade_backup_removal() { + let test = yield prepareTest(); + let maxUpgradeBackups = Preferences.get(PREF_MAX_UPGRADE_BACKUPS, 3); + info("Let's see if we remove backups if there are too many"); + yield OS.File.writeAtomic(Paths.clean, test.contents); + + // if the nextUpgradeBackup already exists (from another test), remove it + if (OS.File.exists(Paths.nextUpgradeBackup)) { + yield OS.File.remove(Paths.nextUpgradeBackup); + } + + // create dummy backups + yield OS.File.writeAtomic(Paths.upgradeBackupPrefix + "20080101010101", ""); + yield OS.File.writeAtomic(Paths.upgradeBackupPrefix + "20090101010101", ""); + yield OS.File.writeAtomic(Paths.upgradeBackupPrefix + "20100101010101", ""); + yield OS.File.writeAtomic(Paths.upgradeBackupPrefix + "20110101010101", ""); + yield OS.File.writeAtomic(Paths.upgradeBackupPrefix + "20120101010101", ""); + yield OS.File.writeAtomic(Paths.upgradeBackupPrefix + "20130101010101", ""); + + // get currently existing backups + let backups = yield getUpgradeBackups(); + + // trigger new backup + yield SessionFile.read(); // First call to read() initializes the SessionWorker + yield 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"); + is((yield OS.File.exists(Paths.upgradeBackup)), true, "upgrade backup file has been created"); + + // get currently existing backups and check their count + let newBackups = yield 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.indexOf(backup) < 0; + }); + + // check that exactly one new backup was created + is(newBackups.length, 1, "one new backup was created that was not removed"); + + yield SessionFile.write(""); // Second call to write() should not trigger anything + + backups = yield 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_windowRestore_perwindowpb.js b/browser/components/sessionstore/test/browser_windowRestore_perwindowpb.js new file mode 100644 index 000000000..781692909 --- /dev/null +++ b/browser/components/sessionstore/test/browser_windowRestore_perwindowpb.js @@ -0,0 +1,26 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public +* License, v. 2.0. If a copy of the MPL was not distributed with this +* file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// 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 onclosing() { + win.removeEventListener("SSWindowClosing", onclosing, false); + executeSoon(function () { + is(ss.getClosedWindowCount(), 0, + "The private window should not have been stored"); + }); + }, false); + 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 000000000..beb838088 --- /dev/null +++ b/browser/components/sessionstore/test/browser_windowStateContainer.js @@ -0,0 +1,122 @@ +"use strict"; + +requestLongerTimeout(2); + +add_task(function* setup() { + yield SpecialPowers.pushPrefEnv({ + set: [["dom.ipc.processCount", 1]] + }); +}); + +add_task(function* () { + let win = yield BrowserTestUtils.openNewBrowserWindow(); + + // Create 4 tabs with different userContextId. + for (let userContextId = 1; userContextId < 5; userContextId++) { + let tab = win.gBrowser.addTab("http://example.com/", {userContextId}); + yield promiseBrowserLoaded(tab.linkedBrowser); + yield 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 = JSON.parse(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 = yield 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 = win2.gBrowser.addTab("http://example.com/", {userContextId}); + yield promiseBrowserLoaded(tab.linkedBrowser); + yield TabStateFlusher.flush(tab.linkedBrowser); + } + + ss.setWindowState(win2, JSON.stringify(winState), true); + + for (let i = 0; i < 4; i++) { + let browser = win2.gBrowser.tabs[i].linkedBrowser; + yield ContentTask.spawn(browser, { expectedId: i + 1 }, 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; + yield ContentTask.spawn(browser, { expectedId: 0 }, 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"); + }); + + yield BrowserTestUtils.closeWindow(win); + yield BrowserTestUtils.closeWindow(win2); +}); + +add_task(function* () { + let win = yield BrowserTestUtils.openNewBrowserWindow(); + yield TabStateFlusher.flush(win.gBrowser.selectedBrowser); + + let tab = win.gBrowser.addTab("http://example.com/", { userContextId: 1 }); + yield promiseBrowserLoaded(tab.linkedBrowser); + yield 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 = JSON.parse(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 = yield BrowserTestUtils.openNewBrowserWindow(); + + let tab2 = win2.gBrowser.addTab("http://example.com/", { userContextId : 1 }); + yield promiseBrowserLoaded(tab2.linkedBrowser); + yield 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); + yield TabStateFlusher.flush(win2.gBrowser.tabs[0].linkedBrowser); + + ss.setWindowState(win2, JSON.stringify(winState), true); + + for (let i = 0; i < 2; i++) { + let browser = win2.gBrowser.tabs[i].linkedBrowser; + yield ContentTask.spawn(browser, { expectedId: i }, 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"); + }); + } + + yield BrowserTestUtils.closeWindow(win); + yield BrowserTestUtils.closeWindow(win2); +}); + diff --git a/browser/components/sessionstore/test/content-forms.js b/browser/components/sessionstore/test/content-forms.js new file mode 100644 index 000000000..da7bc9c08 --- /dev/null +++ b/browser/components/sessionstore/test/content-forms.js @@ -0,0 +1,133 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +var {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components; + +/** + * This frame script is only loaded for sessionstore mochitests. It contains + * a bunch of utility functions used to test form data collection and + * restoration in remote browsers. + */ + +function queryElement(data) { + let frame = content; + if (data.hasOwnProperty("frame")) { + frame = content.frames[data.frame]; + } + + let doc = frame.document; + + if (data.hasOwnProperty("id")) { + return doc.getElementById(data.id); + } + + if (data.hasOwnProperty("selector")) { + return doc.querySelector(data.selector); + } + + if (data.hasOwnProperty("xpath")) { + let xptype = Ci.nsIDOMXPathResult.FIRST_ORDERED_NODE_TYPE; + return doc.evaluate(data.xpath, doc, null, xptype, null).singleNodeValue; + } + + throw new Error("couldn't query element"); +} + +function dispatchUIEvent(input, type) { + let event = input.ownerDocument.createEvent("UIEvents"); + event.initUIEvent(type, true, true, input.ownerGlobal, 0); + input.dispatchEvent(event); +} + +function defineListener(type, cb) { + addMessageListener("ss-test:" + type, function ({data}) { + sendAsyncMessage("ss-test:" + type, cb(data)); + }); +} + +defineListener("sendKeyEvent", function (data) { + let frame = content; + if (data.hasOwnProperty("frame")) { + frame = content.frames[data.frame]; + } + + let ifreq = frame.QueryInterface(Ci.nsIInterfaceRequestor); + let utils = ifreq.getInterface(Ci.nsIDOMWindowUtils); + + let keyCode = data.key.charCodeAt(0); + let charCode = Ci.nsIDOMKeyEvent.DOM_VK_A + keyCode - "a".charCodeAt(0); + + utils.sendKeyEvent("keydown", keyCode, charCode, null); + utils.sendKeyEvent("keypress", keyCode, charCode, null); + utils.sendKeyEvent("keyup", keyCode, charCode, null); +}); + +defineListener("getInnerHTML", function (data) { + return queryElement(data).innerHTML; +}); + +defineListener("getTextContent", function (data) { + return queryElement(data).textContent; +}); + +defineListener("getInputValue", function (data) { + return queryElement(data).value; +}); + +defineListener("setInputValue", function (data) { + let input = queryElement(data); + input.value = data.value; + dispatchUIEvent(input, "input"); +}); + +defineListener("getInputChecked", function (data) { + return queryElement(data).checked; +}); + +defineListener("setInputChecked", function (data) { + let input = queryElement(data); + input.checked = data.checked; + dispatchUIEvent(input, "change"); +}); + +defineListener("getSelectedIndex", function (data) { + return queryElement(data).selectedIndex; +}); + +defineListener("setSelectedIndex", function (data) { + let input = queryElement(data); + input.selectedIndex = data.index; + dispatchUIEvent(input, "change"); +}); + +defineListener("getMultipleSelected", function (data) { + let input = queryElement(data); + return Array.map(input.options, (opt, idx) => idx) + .filter(idx => input.options[idx].selected); +}); + +defineListener("setMultipleSelected", function (data) { + let input = queryElement(data); + Array.forEach(input.options, (opt, idx) => opt.selected = data.indices.indexOf(idx) > -1); + dispatchUIEvent(input, "change"); +}); + +defineListener("getFileNameArray", function (data) { + return queryElement(data).mozGetFileNameArray(); +}); + +defineListener("setFileNameArray", function (data) { + let input = queryElement(data); + input.mozSetFileNameArray(data.names, data.names.length); + dispatchUIEvent(input, "input"); +}); + +defineListener("setFormElementValues", function (data) { + for (let elem of content.document.forms[0].elements) { + elem.value = data.value; + dispatchUIEvent(elem, "input"); + } +}); diff --git a/browser/components/sessionstore/test/content.js b/browser/components/sessionstore/test/content.js new file mode 100644 index 000000000..e815a6783 --- /dev/null +++ b/browser/components/sessionstore/test/content.js @@ -0,0 +1,222 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +var Cu = Components.utils; +var Ci = Components.interfaces; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource:///modules/sessionstore/FrameTree.jsm", this); +var gFrameTree = new FrameTree(this); + +function executeSoon(callback) { + Services.tm.mainThread.dispatch(callback, Components.interfaces.nsIThread.DISPATCH_NORMAL); +} + +gFrameTree.addObserver({ + onFrameTreeReset: function () { + sendAsyncMessage("ss-test:onFrameTreeReset"); + }, + + onFrameTreeCollected: function () { + sendAsyncMessage("ss-test:onFrameTreeCollected"); + } +}); + +var historyListener = { + OnHistoryNewEntry: function () { + sendAsyncMessage("ss-test:OnHistoryNewEntry"); + }, + + OnHistoryGoBack: function () { + sendAsyncMessage("ss-test:OnHistoryGoBack"); + return true; + }, + + OnHistoryGoForward: function () { + sendAsyncMessage("ss-test:OnHistoryGoForward"); + return true; + }, + + OnHistoryGotoIndex: function () { + sendAsyncMessage("ss-test:OnHistoryGotoIndex"); + return true; + }, + + OnHistoryPurge: function () { + sendAsyncMessage("ss-test:OnHistoryPurge"); + return true; + }, + + OnHistoryReload: function () { + sendAsyncMessage("ss-test:OnHistoryReload"); + return true; + }, + + OnHistoryReplaceEntry: function () { + sendAsyncMessage("ss-test:OnHistoryReplaceEntry"); + }, + + QueryInterface: XPCOMUtils.generateQI([ + Ci.nsISHistoryListener, + Ci.nsISupportsWeakReference + ]) +}; + +var {sessionHistory} = docShell.QueryInterface(Ci.nsIWebNavigation); +if (sessionHistory) { + sessionHistory.addSHistoryListener(historyListener); +} + +/** + * This frame script is only loaded for sessionstore mochitests. It enables us + * to modify and query docShell data when running with multiple processes. + */ + +addEventListener("hashchange", function () { + sendAsyncMessage("ss-test:hashchange"); +}); + +addMessageListener("ss-test:purgeDomainData", function ({data: domain}) { + Services.obs.notifyObservers(null, "browser:purge-domain-data", domain); + content.setTimeout(() => sendAsyncMessage("ss-test:purgeDomainData")); +}); + +addMessageListener("ss-test:getStyleSheets", function (msg) { + let sheets = content.document.styleSheets; + let titles = Array.map(sheets, ss => [ss.title, ss.disabled]); + sendSyncMessage("ss-test:getStyleSheets", titles); +}); + +addMessageListener("ss-test:enableStyleSheetsForSet", function (msg) { + let sheets = content.document.styleSheets; + let change = false; + for (let i = 0; i < sheets.length; i++) { + if (sheets[i].disabled != (msg.data.indexOf(sheets[i].title) == -1)) { + change = true; + break; + } + } + function observer() { + Services.obs.removeObserver(observer, "style-sheet-applicable-state-changed"); + + // It's possible our observer will run before the one in + // content-sessionStore.js. Therefore, we run ours a little + // later. + executeSoon(() => sendAsyncMessage("ss-test:enableStyleSheetsForSet")); + } + if (change) { + // We don't want to reply until content-sessionStore.js has seen + // the change. + Services.obs.addObserver(observer, "style-sheet-applicable-state-changed", false); + + content.document.enableStyleSheetsForSet(msg.data); + } else { + sendAsyncMessage("ss-test:enableStyleSheetsForSet"); + } +}); + +addMessageListener("ss-test:enableSubDocumentStyleSheetsForSet", function (msg) { + let iframe = content.document.getElementById(msg.data.id); + iframe.contentDocument.enableStyleSheetsForSet(msg.data.set); + sendAsyncMessage("ss-test:enableSubDocumentStyleSheetsForSet"); +}); + +addMessageListener("ss-test:getAuthorStyleDisabled", function (msg) { + let {authorStyleDisabled} = + docShell.contentViewer; + sendSyncMessage("ss-test:getAuthorStyleDisabled", authorStyleDisabled); +}); + +addMessageListener("ss-test:setAuthorStyleDisabled", function (msg) { + let markupDocumentViewer = + docShell.contentViewer; + markupDocumentViewer.authorStyleDisabled = msg.data; + sendSyncMessage("ss-test:setAuthorStyleDisabled"); +}); + +addMessageListener("ss-test:setUsePrivateBrowsing", function (msg) { + let loadContext = + docShell.QueryInterface(Ci.nsILoadContext); + loadContext.usePrivateBrowsing = msg.data; + sendAsyncMessage("ss-test:setUsePrivateBrowsing"); +}); + +addMessageListener("ss-test:getScrollPosition", function (msg) { + let frame = content; + if (msg.data.hasOwnProperty("frame")) { + frame = content.frames[msg.data.frame]; + } + let {scrollX: x, scrollY: y} = frame; + sendAsyncMessage("ss-test:getScrollPosition", {x: x, y: y}); +}); + +addMessageListener("ss-test:setScrollPosition", function (msg) { + let frame = content; + let {x, y} = msg.data; + if (msg.data.hasOwnProperty("frame")) { + frame = content.frames[msg.data.frame]; + } + frame.scrollTo(x, y); + + frame.addEventListener("scroll", function onScroll(event) { + if (frame.document == event.target) { + frame.removeEventListener("scroll", onScroll); + sendAsyncMessage("ss-test:setScrollPosition"); + } + }); +}); + +addMessageListener("ss-test:createDynamicFrames", function ({data}) { + function createIFrame(rows) { + let frames = content.document.getElementById(data.id); + frames.setAttribute("rows", rows); + + let frame = content.document.createElement("frame"); + frame.setAttribute("src", data.url); + frames.appendChild(frame); + } + + addEventListener("DOMContentLoaded", function onContentLoaded(event) { + if (content.document == event.target) { + removeEventListener("DOMContentLoaded", onContentLoaded, true); + // DOMContentLoaded is fired right after we finished parsing the document. + createIFrame("33%, 33%, 33%"); + } + }, true); + + addEventListener("load", function onLoad(event) { + if (content.document == event.target) { + removeEventListener("load", onLoad, true); + + // Creating this frame on the same tick as the load event + // means that it must not be included in the frame tree. + createIFrame("25%, 25%, 25%, 25%"); + } + }, true); + + sendAsyncMessage("ss-test:createDynamicFrames"); +}); + +addMessageListener("ss-test:removeLastFrame", function ({data}) { + let frames = content.document.getElementById(data.id); + frames.lastElementChild.remove(); + sendAsyncMessage("ss-test:removeLastFrame"); +}); + +addMessageListener("ss-test:mapFrameTree", function (msg) { + let result = gFrameTree.map(frame => ({href: frame.location.href})); + sendAsyncMessage("ss-test:mapFrameTree", result); +}); + +addMessageListener("ss-test:click", function ({data}) { + content.document.getElementById(data.id).click(); + sendAsyncMessage("ss-test:click"); +}); + +addEventListener("load", function(event) { + let subframe = event.target != content.document; + sendAsyncMessage("ss-test:loadEvent", {subframe: subframe, url: event.target.documentURI}); +}, true); diff --git a/browser/components/sessionstore/test/head.js b/browser/components/sessionstore/test/head.js new file mode 100644 index 000000000..5a8c5dbfc --- /dev/null +++ b/browser/components/sessionstore/test/head.js @@ -0,0 +1,564 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 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 FRAME_SCRIPTS = [ + ROOT + "content.js", + ROOT + "content-forms.js" +]; + +var mm = Cc["@mozilla.org/globalmessagemanager;1"] + .getService(Ci.nsIMessageListenerManager); + +for (let script of FRAME_SCRIPTS) { + mm.loadFrameScript(script, true); +} + +registerCleanupFunction(() => { + for (let script of FRAME_SCRIPTS) { + mm.removeDelayedFrameScript(script, true); + } +}); + +const {Promise} = Cu.import("resource://gre/modules/Promise.jsm", {}); +const {SessionStore} = Cu.import("resource:///modules/sessionstore/SessionStore.jsm", {}); +const {SessionSaver} = Cu.import("resource:///modules/sessionstore/SessionSaver.jsm", {}); +const {SessionFile} = Cu.import("resource:///modules/sessionstore/SessionFile.jsm", {}); +const {TabState} = Cu.import("resource:///modules/sessionstore/TabState.jsm", {}); +const {TabStateFlusher} = Cu.import("resource:///modules/sessionstore/TabStateFlusher.jsm", {}); + +const ss = Cc["@mozilla.org/browser/sessionstore;1"].getService(Ci.nsISessionStore); + +// 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(getBrowserURL(), "", 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 selectedBrowserLoadListener() { + aWin.gBrowser.selectedBrowser.removeEventListener("load", selectedBrowserLoadListener, true); + callbackSoon(aWin); + }, 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"); + + aState.windows.forEach(function (winState) { + winState.tabs.forEach(function (tabState) { + if (restoreHiddenTabs || !tabState.hidden) + expectedTabsRestored++; + }); + }); + + // There must be only hidden tabs and restoreHiddenTabs = false. We still + // expect one of them to be restored because it gets shown automatically. + if (!expectedTabsRestored) + expectedTabsRestored = 1; + + 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.QueryInterface(Ci.nsIDOMWindow); + newWindow.addEventListener("load", function() { + newWindow.removeEventListener("load", arguments.callee, false); + + 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); + }, false); + } + } + + // 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; +} + +/** + * Wait for a content -> chrome message. + */ +function promiseContentMessage(browser, name) { + let mm = browser.messageManager; + + return new Promise(resolve => { + function removeListener() { + mm.removeMessageListener(name, listener); + } + + function listener(msg) { + removeListener(); + resolve(msg.data); + } + + mm.addMessageListener(name, listener); + registerCleanupFunction(removeListener); + }); +} + +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(aSubject, aTopic, aData) { + removeObserver(); + timeout = clearTimeout(timeout); + executeSoon(() => aCallback(true)); + } + + registerCleanupFunction(function() { + removeObserver(); + if (timeout) { + clearTimeout(timeout); + } + }); + + observing = true; + Services.obs.addObserver(observer, aTopic, false); +} + +/** + * 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 => { + waitForSaveState(isSuccessful => { + if (!isSuccessful) { + throw new Error("timeout"); + } + + resolve(); + }); + }); +} +function forceSaveState() { + return SessionSaver.run(); +} + +function promiseRecoveryFileContents() { + let promise = forceSaveState(); + return promise.then(function() { + return OS.File.read(SessionFile.Paths.recovery, { encoding: "utf-8" }); + }); +} + +var promiseForEachSessionRestoreFile = Task.async(function*(cb) { + for (let key of SessionFile.Paths.loadOrder) { + let data = ""; + try { + data = yield OS.File.read(SessionFile.Paths[key], { encoding: "utf-8" }); + } catch (ex) { + // Ignore missing files + if (!(ex instanceof OS.File.Error && ex.becauseNoSuchFile)) { + throw ex; + } + } + cb(data, key); + } +}); + +function promiseBrowserLoaded(aBrowser, ignoreSubFrames = true, wantLoad = null) { + return BrowserTestUtils.browserLoaded(aBrowser, !ignoreSubFrames, wantLoad); +} + +function whenWindowLoaded(aWindow, aCallback = next) { + aWindow.addEventListener("load", function windowLoadListener() { + aWindow.removeEventListener("load", windowLoadListener, false); + executeSoon(function executeWhenWindowLoaded() { + aCallback(aWindow); + }); + }, false); +} +function promiseWindowLoaded(aWindow) { + return new Promise(resolve => whenWindowLoaded(aWindow, resolve)); +} + +var gUniqueCounter = 0; +function r() { + return Date.now() + "-" + (++gUniqueCounter); +} + +function* BrowserWindowIterator() { + let windowsEnum = Services.wm.getEnumerator("navigator:browser"); + while (windowsEnum.hasMoreElements()) { + let currentWindow = windowsEnum.getNext(); + if (!currentWindow.closed) { + yield currentWindow; + } + } +} + +var gWebProgressListener = { + _callback: null, + + setCallback: function (aCallback) { + if (!this._callback) { + window.gBrowser.addTabsProgressListener(this); + } + this._callback = aCallback; + }, + + unsetCallback: function () { + if (this._callback) { + this._callback = null; + window.gBrowser.removeTabsProgressListener(this); + } + }, + + onStateChange: function (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: function (callback) { + Services.obs.addObserver(this, "sessionstore-debug-tab-restored", false); + this._callback = callback; + }, + + unsetCallback: function () { + if (this._callback) { + this._callback = null; + Services.obs.removeObserver(this, "sessionstore-debug-tab-restored"); + } + }, + + observe: function (browser, topic, data) { + gProgressListener.onRestored(browser); + }, + + onRestored: function (browser) { + if (browser.__SS_restoreState == TAB_STATE_RESTORING) { + let args = [browser].concat(gProgressListener._countTabs()); + gProgressListener._callback.apply(gProgressListener, args); + } + }, + + _countTabs: function () { + 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; + if (!browser.__SS_restoreState) + wasRestored++; + else if (browser.__SS_restoreState == TAB_STATE_RESTORING) + isRestoring++; + else if (browser.__SS_restoreState == TAB_STATE_NEEDS_RESTORE) + 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); + } +} + +/** + * 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(getBrowserURL(), "", "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 onLoad() { + win.removeEventListener("load", onLoad); + let browser = win.gBrowser.selectedBrowser; + promiseBrowserLoaded(browser).then(resolve); + }); + }); + + 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", false); +} +function promiseDelayedStartupFinished(aWindow) { + return new Promise(resolve => whenDelayedStartupFinished(aWindow, resolve)); +} + +function promiseEvent(element, eventType, isCapturing = false) { + return new Promise(resolve => { + element.addEventListener(eventType, function listener(event) { + element.removeEventListener(eventType, listener, isCapturing); + resolve(event); + }, isCapturing); + }); +} + +function promiseTabRestored(tab) { + return promiseEvent(tab, "SSTabRestored"); +} + +function promiseTabRestoring(tab) { + return promiseEvent(tab, "SSTabRestoring"); +} + +function sendMessage(browser, name, data = {}) { + browser.messageManager.sendAsyncMessage(name, data); + return promiseContentMessage(browser, name); +} + +// This creates list of functions that we will map to their corresponding +// ss-test:* messages names. Those will be sent to the frame script and +// be used to read and modify form data. +const FORM_HELPERS = [ + "getTextContent", + "getInputValue", "setInputValue", + "getInputChecked", "setInputChecked", + "getSelectedIndex", "setSelectedIndex", + "getMultipleSelected", "setMultipleSelected", + "getFileNameArray", "setFileNameArray", +]; + +for (let name of FORM_HELPERS) { + let msg = "ss-test:" + name; + this[name] = (browser, data) => sendMessage(browser, msg, data); +} + +// 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 promiseRemoveTab(tab) { + return BrowserTestUtils.removeTab(tab); +} + +// Write DOMSessionStorage data to the given browser. +function modifySessionStorage(browser, data, options = {}) { + return ContentTask.spawn(browser, [data, options], function* ([data, options]) { + let frame = content; + if (options && "frameIndex" in options) { + frame = content.frames[options.frameIndex]; + } + + let keys = new Set(Object.keys(data)); + let storage = frame.sessionStorage; + + return new Promise(resolve => { + addEventListener("MozSessionStorageChanged", function onStorageChanged(event) { + if (event.storageArea == storage) { + keys.delete(event.key); + } + + if (keys.size == 0) { + removeEventListener("MozSessionStorageChanged", onStorageChanged, true); + resolve(); + } + }, true); + + for (let key of keys) { + frame.sessionStorage[key] = data[key]; + } + }); + }); +} + +function pushPrefs(...aPrefs) { + return new Promise(resolve => { + SpecialPowers.pushPrefEnv({"set": aPrefs}, resolve); + }); +} + +function popPrefs() { + return new Promise(resolve => { + SpecialPowers.popPrefEnv(resolve); + }); +} + +function* checkScroll(tab, expected, msg) { + let browser = tab.linkedBrowser; + yield TabStateFlusher.flush(browser); + + let scroll = JSON.parse(ss.getTabState(tab)).scroll || null; + is(JSON.stringify(scroll), JSON.stringify(expected), msg); +} 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 000000000..e69de29bb 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 000000000..533bda36f --- /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 000000000..1f5f0e54c --- /dev/null +++ b/browser/components/sessionstore/test/restore_redirect_js.html @@ -0,0 +1,10 @@ + + + + + + + \ No newline at end of file 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 000000000..6c8b3aae5 --- /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/.eslintrc.js b/browser/components/sessionstore/test/unit/.eslintrc.js new file mode 100644 index 000000000..d35787cd2 --- /dev/null +++ b/browser/components/sessionstore/test/unit/.eslintrc.js @@ -0,0 +1,7 @@ +"use strict"; + +module.exports = { + "extends": [ + "../../../../../testing/xpcshell/xpcshell.eslintrc.js" + ] +}; 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 000000000..928de6a39 --- /dev/null +++ b/browser/components/sessionstore/test/unit/data/sessionCheckpoints_all.json @@ -0,0 +1 @@ +{"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} \ No newline at end of file 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 000000000..a8c3ff2ff --- /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 000000000..f9511f29f --- /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 000000000..b62856012 --- /dev/null +++ b/browser/components/sessionstore/test/unit/head.js @@ -0,0 +1,32 @@ +var Cu = Components.utils; +var Cc = Components.classes; +var Ci = Components.interfaces; + +Components.utils.import("resource://gre/modules/Services.jsm"); + +// Call a function once initialization of SessionStartup is complete +function afterSessionStartupInitialization(cb) { + do_print("Waiting for session startup initialization"); + let observer = function() { + try { + do_print("Session startup initialization observed"); + Services.obs.removeObserver(observer, "sessionstore-state-finalized"); + cb(); + } catch (ex) { + do_throw(ex); + } + }; + + // We need the Crash Monitor initialized for sessionstartup to run + // successfully. + Components.utils.import("resource://gre/modules/CrashMonitor.jsm"); + CrashMonitor.init(); + + // Start sessionstartup initialization. + let startup = Cc["@mozilla.org/browser/sessionstartup;1"]. + getService(Ci.nsIObserver); + Services.obs.addObserver(startup, "final-ui-startup", false); + Services.obs.addObserver(startup, "quit-application", false); + Services.obs.notifyObservers(null, "final-ui-startup", ""); + Services.obs.addObserver(observer, "sessionstore-state-finalized", false); +}; 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 000000000..fff34ad58 --- /dev/null +++ b/browser/components/sessionstore/test/unit/test_backup_once.js @@ -0,0 +1,130 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +var {OS} = Cu.import("resource://gre/modules/osfile.jsm", {}); +var {XPCOMUtils} = Cu.import("resource://gre/modules/XPCOMUtils.jsm", {}); +var {Task} = Cu.import("resource://gre/modules/Task.jsm", {}); +var {SessionWorker} = Cu.import("resource:///modules/sessionstore/SessionWorker.jsm", {}); + +var File = OS.File; +var Paths; +var SessionFile; + +// We need a XULAppInfo to initialize SessionFile +Cu.import("resource://testing-common/AppInfo.jsm", this); +updateAppInfo({ + name: "SessionRestoreTest", + ID: "{230de50e-4cd1-11dc-8314-0800200c9a66}", + version: "1", + platformVersion: "", +}); + +function run_test() { + run_next_test(); +} + +add_task(function* init() { + // Make sure that we have a profile before initializing SessionFile + let profd = do_get_profile(); + SessionFile = Cu.import("resource:///modules/sessionstore/SessionFile.jsm", {}).SessionFile; + Paths = SessionFile.Paths; + + + let source = do_get_file("data/sessionstore_valid.js"); + source.copyTo(profd, "sessionstore.js"); + + // Finish initialization of SessionFile + yield SessionFile.read(); +}); + +var pathStore; +var pathBackup; +var decoder; + +function promise_check_exist(path, shouldExist) { + return Task.spawn(function*() { + do_print("Ensuring that " + path + (shouldExist?" exists":" does not exist")); + if ((yield OS.File.exists(path)) != shouldExist) { + throw new Error("File " + path + " should " + (shouldExist?"exist":"not exist")); + } + }); +} + +function promise_check_contents(path, expect) { + return Task.spawn(function*() { + do_print("Checking whether " + path + " has the right contents"); + let actual = yield OS.File.read(path, { encoding: "utf-8"}); + Assert.deepEqual(JSON.parse(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(function* test_first_write_backup() { + let initial_content = generateFileContents("initial"); + let new_content = generateFileContents("test_1"); + + do_print("Before the first write, none of the files should exist"); + yield promise_check_exist(Paths.backups, false); + + yield File.makeDir(Paths.backups); + yield File.writeAtomic(Paths.clean, JSON.stringify(initial_content), { encoding: "utf-8" }); + yield SessionFile.write(new_content); + + do_print("After first write, a few files should have been created"); + yield promise_check_exist(Paths.backups, true); + yield promise_check_exist(Paths.clean, false); + yield promise_check_exist(Paths.cleanBackup, true); + yield promise_check_exist(Paths.recovery, true); + yield promise_check_exist(Paths.recoveryBackup, false); + yield promise_check_exist(Paths.nextUpgradeBackup, true); + + yield promise_check_contents(Paths.recovery, new_content); + yield 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(function* test_second_write_no_backup() { + let new_content = generateFileContents("test_2"); + let previous_backup_content = yield File.read(Paths.recovery, { encoding: "utf-8" }); + previous_backup_content = JSON.parse(previous_backup_content); + + yield OS.File.remove(Paths.cleanBackup); + + yield SessionFile.write(new_content); + + yield promise_check_exist(Paths.backups, true); + yield promise_check_exist(Paths.clean, false); + yield promise_check_exist(Paths.cleanBackup, false); + yield promise_check_exist(Paths.recovery, true); + yield promise_check_exist(Paths.nextUpgradeBackup, true); + + yield promise_check_contents(Paths.recovery, new_content); + yield promise_check_contents(Paths.recoveryBackup, previous_backup_content); +}); + +// Make sure that we create $Paths.clean and remove $Paths.recovery* +// upon shutdown +add_task(function* test_shutdown() { + let output = generateFileContents("test_3"); + + yield File.writeAtomic(Paths.recovery, "I should disappear"); + yield File.writeAtomic(Paths.recoveryBackup, "I should also disappear"); + + yield SessionWorker.post("write", [output, { isFinalWrite: true, performShutdownCleanup: true}]); + + do_check_false((yield File.exists(Paths.recovery))); + do_check_false((yield File.exists(Paths.recoveryBackup))); + yield promise_check_contents(Paths.clean, output); +}); 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 000000000..c7d8b03ed --- /dev/null +++ b/browser/components/sessionstore/test/unit/test_histogram_corrupt_files.js @@ -0,0 +1,114 @@ +/* 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"; +Cu.import("resource://gre/modules/osfile.jsm", this); + +const Telemetry = Services.telemetry; +const Path = OS.Path; +const HistogramId = "FX_SESSION_RESTORE_ALL_FILES_CORRUPT"; + +// Prepare the session file. +var profd = do_get_profile(); +Cu.import("resource:///modules/sessionstore/SessionFile.jsm", this); + +/** + * A utility function for resetting the histogram and the contents + * of the backup directory. + */ +function reset_session(backups = {}) { + + // Reset the histogram. + Telemetry.getHistogramById(HistogramId).clear(); + + // Reset the contents of the backups directory + OS.File.makeDir(SessionFile.Paths.backups); + for (let key of SessionFile.Paths.loadOrder) { + if (backups.hasOwnProperty(key)) { + OS.File.copy(backups[key], SessionFile.Paths[key]); + } else { + OS.File.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(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(function* test_no_files_exist() { + // No session files are available to SessionFile. + reset_session(); + + yield SessionFile.read(); + // Checking if the histogram is updated negatively + let h = Telemetry.getHistogramById(HistogramId); + let s = h.snapshot(); + Assert.equal(s.counts[0], 1, "One probe for the 'false' bucket."); + Assert.equal(s.counts[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(function* test_one_file_valid() { + // Corrupting some backup files. + let invalidSession = "data/sessionstore_invalid.js"; + let validSession = "data/sessionstore_valid.js"; + reset_session({ + clean : invalidSession, + cleanBackup: validSession, + recovery: invalidSession, + recoveryBackup: invalidSession + }); + + yield SessionFile.read(); + // Checking if the histogram is updated negatively. + let h = Telemetry.getHistogramById(HistogramId); + let s = h.snapshot(); + Assert.equal(s.counts[0], 1, "One probe for the 'false' bucket."); + Assert.equal(s.counts[1], 0, "No probes in the 'true' bucket."); +}); + +/** + * Makes sure that the histogram is positively updated when all + * backup files are corrupted. + */ +add_task(function* test_all_files_corrupt() { + // Corrupting all backup files. + let invalidSession = "data/sessionstore_invalid.js"; + reset_session({ + clean : invalidSession, + cleanBackup: invalidSession, + recovery: invalidSession, + recoveryBackup: invalidSession + }); + + yield SessionFile.read(); + // Checking if the histogram is positively updated. + let h = Telemetry.getHistogramById(HistogramId); + let s = h.snapshot(); + Assert.equal(s.counts[1], 1, "One probe for the 'true' bucket."); + Assert.equal(s.counts[0], 0, "No probes in the 'false' bucket."); +}); + +function run_test() { + run_next_test(); +} diff --git a/browser/components/sessionstore/test/unit/test_shutdown_cleanup.js b/browser/components/sessionstore/test/unit/test_shutdown_cleanup.js new file mode 100644 index 000000000..b99e566e9 --- /dev/null +++ b/browser/components/sessionstore/test/unit/test_shutdown_cleanup.js @@ -0,0 +1,127 @@ +"use strict"; + +/** + * This test ensures that we correctly clean up the session state before + * writing to disk a last time on shutdown. For now it only tests that each + * tab's shistory is capped to a maximum number of preceding and succeeding + * entries. + */ + +const {XPCOMUtils} = Cu.import("resource://gre/modules/XPCOMUtils.jsm", {}); +const {Task} = Cu.import("resource://gre/modules/Task.jsm", {}); +const {SessionWorker} = Cu.import("resource:///modules/sessionstore/SessionWorker.jsm", {}); + +const profd = do_get_profile(); +const {SessionFile} = Cu.import("resource:///modules/sessionstore/SessionFile.jsm", {}); +const {Paths} = SessionFile; + +const {OS} = Cu.import("resource://gre/modules/osfile.jsm", {}); +const {File} = OS; + +const MAX_ENTRIES = 9; +const URL = "http://example.com/#"; + +// We need a XULAppInfo to initialize SessionFile +Cu.import("resource://testing-common/AppInfo.jsm", this); +updateAppInfo({ + name: "SessionRestoreTest", + ID: "{230de50e-4cd1-11dc-8314-0800200c9a66}", + version: "1", + platformVersion: "", +}); + +add_task(function* setup() { + let source = do_get_file("data/sessionstore_valid.js"); + source.copyTo(profd, "sessionstore.js"); + + // Finish SessionFile initialization. + yield SessionFile.read(); + + // Reset prefs on cleanup. + do_register_cleanup(() => { + Services.prefs.clearUserPref("browser.sessionstore.max_serialize_back"); + Services.prefs.clearUserPref("browser.sessionstore.max_serialize_forward"); + }); +}); + +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]}]}; +} + +function* setMaxBackForward(back, fwd) { + Services.prefs.setIntPref("browser.sessionstore.max_serialize_back", back); + Services.prefs.setIntPref("browser.sessionstore.max_serialize_forward", fwd); + yield SessionFile.read(); +} + +function* writeAndParse(state, path, options = {}) { + yield SessionWorker.post("write", [state, options]); + return JSON.parse(yield File.read(path, {encoding: "utf-8"})); +} + +add_task(function* test_shistory_cap_none() { + let state = createSessionState(5); + + // Don't limit the number of shistory entries. + yield setMaxBackForward(-1, -1); + + // Check that no caps are applied. + let diskState = yield writeAndParse(state, Paths.clean, {isFinalWrite: true}); + Assert.deepEqual(state, diskState, "no cap applied"); +}); + +add_task(function* test_shistory_cap_middle() { + let state = createSessionState(5); + yield setMaxBackForward(2, 3); + + // Cap is only applied on clean shutdown. + let diskState = yield 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 = yield 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(function* test_shistory_cap_lower_bound() { + let state = createSessionState(1); + yield setMaxBackForward(5, 5); + + // Cap is only applied on clean shutdown. + let diskState = yield writeAndParse(state, Paths.recovery); + Assert.deepEqual(state, diskState, "no cap applied"); + + // Check that the right number of shistory entries was discarded. + diskState = yield 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(function* test_shistory_cap_upper_bound() { + let state = createSessionState(MAX_ENTRIES); + yield setMaxBackForward(5, 5); + + // Cap is only applied on clean shutdown. + let diskState = yield 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 = yield 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_startup_invalid_session.js b/browser/components/sessionstore/test/unit/test_startup_invalid_session.js new file mode 100644 index 000000000..9f6df8585 --- /dev/null +++ b/browser/components/sessionstore/test/unit/test_startup_invalid_session.js @@ -0,0 +1,21 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +function run_test() { + let profd = do_get_profile(); + + 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"); + + do_test_pending(); + let startup = Cc["@mozilla.org/browser/sessionstartup;1"]. + getService(Ci.nsISessionStartup); + + afterSessionStartupInitialization(function cb() { + do_check_eq(startup.sessionType, Ci.nsISessionStartup.NO_SESSION); + do_test_finished(); + }); +} 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 000000000..5185b02d6 --- /dev/null +++ b/browser/components/sessionstore/test/unit/test_startup_nosession_async.js @@ -0,0 +1,22 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + + +// Test nsISessionStartup.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() { + do_get_profile(); + // Initialize the profile (the session startup uses it) + + do_test_pending(); + let startup = Cc["@mozilla.org/browser/sessionstartup;1"]. + getService(Ci.nsISessionStartup); + + afterSessionStartupInitialization(function cb() { + do_check_eq(startup.sessionType, Ci.nsISessionStartup.NO_SESSION); + do_test_finished(); + }); +} \ No newline at end of file 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 000000000..459acf885 --- /dev/null +++ b/browser/components/sessionstore/test/unit/test_startup_session_async.js @@ -0,0 +1,27 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + + +// Test nsISessionStartup.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(); + + 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"); + + do_test_pending(); + let startup = Cc["@mozilla.org/browser/sessionstartup;1"]. + getService(Ci.nsISessionStartup); + + afterSessionStartupInitialization(function cb() { + do_check_eq(startup.sessionType, Ci.nsISessionStartup.DEFER_SESSION); + do_test_finished(); + }); +} diff --git a/browser/components/sessionstore/test/unit/xpcshell.ini b/browser/components/sessionstore/test/unit/xpcshell.ini new file mode 100644 index 000000000..09980f877 --- /dev/null +++ b/browser/components/sessionstore/test/unit/xpcshell.ini @@ -0,0 +1,16 @@ +[DEFAULT] +head = head.js +tail = +firefox-appdir = browser +skip-if = toolkit == 'android' +support-files = + data/sessionCheckpoints_all.json + data/sessionstore_invalid.js + data/sessionstore_valid.js + +[test_backup_once.js] +[test_histogram_corrupt_files.js] +[test_shutdown_cleanup.js] +[test_startup_nosession_async.js] +[test_startup_session_async.js] +[test_startup_invalid_session.js] -- cgit v1.2.3