diff --git a/CMakeLists.txt b/CMakeLists.txt index 92f429f..980c86b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -41,6 +41,7 @@ set(NEBULA_SOURCES app/main.cpp src/app/nebula_controller.cpp src/app/run.cpp + src/browser/session_state.cpp src/browser/tab.cpp src/browser/tab_manager.cpp src/browser/url_utils.cpp diff --git a/src/app/nebula_controller.cpp b/src/app/nebula_controller.cpp index 33bfbe2..a339080 100644 --- a/src/app/nebula_controller.cpp +++ b/src/app/nebula_controller.cpp @@ -6,9 +6,11 @@ #include #include +#include "browser/session_state.h" #include "browser/url_utils.h" #include "include/cef_app.h" #include "include/cef_browser.h" +#include "include/cef_cookie.h" #include "include/wrapper/cef_helpers.h" #include "ui/paths.h" @@ -37,6 +39,10 @@ CefWindowInfo ChildWindowInfo(HWND parent, const RECT& rect) { rect.top, rect.right - rect.left, rect.bottom - rect.top)); + // CEF defaults to the Chrome runtime style, which ignores the + // SetAsChild hint and creates a top-level window per browser. Force the + // Alloy runtime style so each browser embeds inside the Nebula HWND. + info.runtime_style = CEF_RUNTIME_STYLE_ALLOY; return info; } @@ -145,7 +151,17 @@ bool NebulaController::Create() { } void NebulaController::OnWindowCreated() { - tabs_.CreateInitialTab(initial_url_.empty() ? nebula::ui::GetHomeUrl() : initial_url_); + if (initial_url_.empty()) { + const auto session = nebula::browser::LoadSessionState(); + if (!session.tabs.empty()) { + tabs_.RestoreTabs(session.tabs, session.active_tab_index); + } else { + tabs_.CreateInitialTab(nebula::ui::GetHomeUrl()); + } + } else { + tabs_.CreateInitialTab(initial_url_); + } + CreateChromeBrowser(); CreateContentBrowser(); } @@ -157,11 +173,23 @@ void NebulaController::OnWindowResized(const nebula::window::BrowserLayout& layo void NebulaController::OnWindowCloseRequested() { if (closing_) { + // CEF re-sends WM_CLOSE to the top-level window after each Alloy + // child browser finishes its JS unload + DoClose phase. Destroy the + // Nebula window now so CEF can tear down the child browser HWNDs and + // fire OnBeforeClose; MaybeFinishShutdown will then quit the loop. + if (window_ && window_->hwnd()) { + DestroyWindow(window_->hwnd()); + } MaybeFinishShutdown(); return; } closing_ = true; + PersistSession(); + if (auto cookie_manager = CefCookieManager::GetGlobalManager(nullptr)) { + cookie_manager->FlushStore(nullptr); + } + if (chrome_browser_) { chrome_browser_->GetHost()->CloseBrowser(false); } @@ -173,7 +201,6 @@ void NebulaController::OnWindowCloseRequested() { tab.browser->GetHost()->CloseBrowser(false); } } - MaybeFinishShutdown(); } void NebulaController::OnActiveTabChanged(const nebula::browser::NebulaTab& tab) { @@ -292,10 +319,12 @@ void NebulaController::OnContentAddressChanged(CefRefPtr browser, co nebula::ui::IsChromiumNewTabUrl(url) ? nebula::ui::GetHomeUrl() : nebula::ui::ToInternalUrl(url)); + PersistSession(); } void NebulaController::OnContentTitleChanged(CefRefPtr browser, const std::string& title) { tabs_.UpdateTitle(browser, title); + PersistSession(); const auto* active_tab = tabs_.ActiveTab(); if (window_ && active_tab && active_tab->browser && active_tab->browser->IsSame(browser)) { window_->SetTitle(Utf8ToWide(title.empty() ? "Nebula Browser" : title + " - Nebula")); @@ -353,6 +382,7 @@ void NebulaController::CreateNewTab() { } tabs_.CreateTab(nebula::ui::GetHomeUrl()); + PersistSession(); CreateContentBrowser(); } @@ -366,6 +396,7 @@ void NebulaController::ActivateTab(int tab_id) { if (!tabs_.ActivateTab(tab_id)) { return; } + PersistSession(); SetBrowserVisible(previous_browser, false); if (auto* active_tab = tabs_.ActiveTab()) { @@ -385,12 +416,14 @@ void NebulaController::CloseTab(int tab_id) { }(); CefRefPtr closing_browser = tabs_.CloseTab(tab_id); + PersistSession(); if (closing_browser) { closing_browser->GetHost()->CloseBrowser(false); } if (!tabs_.ActiveTab()) { tabs_.CreateTab(nebula::ui::GetHomeUrl()); + PersistSession(); CreateContentBrowser(); return; } @@ -485,6 +518,7 @@ void NebulaController::ToggleDevTools() { CefWindowInfo window_info; window_info.SetAsPopup(window_->hwnd(), "Nebula Developer Tools"); + window_info.runtime_style = CEF_RUNTIME_STYLE_ALLOY; CefBrowserSettings browser_settings; host->ShowDevTools(window_info, content_client_, browser_settings, CefPoint()); } @@ -580,6 +614,10 @@ void NebulaController::SendChromeState(const nebula::browser::NebulaTab& tab) { chrome_browser_->GetMainFrame()->ExecuteJavaScript(script, nebula::ui::GetChromeUrl(), 0); } +void NebulaController::PersistSession() const { + nebula::browser::SaveSessionState(tabs_.Tabs(), tabs_.ActiveTabIndex()); +} + void NebulaController::MaybeFinishShutdown() { if (!closing_) { return; diff --git a/src/app/nebula_controller.h b/src/app/nebula_controller.h index 6a37737..e10f488 100644 --- a/src/app/nebula_controller.h +++ b/src/app/nebula_controller.h @@ -54,6 +54,7 @@ private: void SetContentFullscreen(bool fullscreen); void ResizeBrowsers(); void SendChromeState(const nebula::browser::NebulaTab& tab); + void PersistSession() const; void MaybeFinishShutdown(); HINSTANCE instance_ = nullptr; diff --git a/src/app/run.cpp b/src/app/run.cpp index 31df932..a6e1816 100644 --- a/src/app/run.cpp +++ b/src/app/run.cpp @@ -9,10 +9,30 @@ namespace nebula::app { namespace { +constexpr wchar_t kMainInstanceMutexName[] = L"Local\\NebulaBrowserMainInstance"; + void EnableDpiAwareness() { SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2); } +class ScopedHandle { +public: + explicit ScopedHandle(HANDLE handle) : handle_(handle) {} + ~ScopedHandle() { + if (handle_) { + CloseHandle(handle_); + } + } + + ScopedHandle(const ScopedHandle&) = delete; + ScopedHandle& operator=(const ScopedHandle&) = delete; + + bool valid() const { return handle_ != nullptr; } + +private: + HANDLE handle_ = nullptr; +}; + } // namespace int RunNebula(HINSTANCE instance, int show_command) { @@ -26,8 +46,14 @@ int RunNebula(HINSTANCE instance, int show_command) { return subprocess_exit_code; } + ScopedHandle main_instance_mutex(CreateMutexW(nullptr, TRUE, kMainInstanceMutexName)); + if (main_instance_mutex.valid() && GetLastError() == ERROR_ALREADY_EXISTS) { + return 0; + } + CefSettings settings; settings.no_sandbox = true; + settings.persist_session_cookies = true; // A persistent profile is required for the GPU shader cache and several // hardware acceleration features. Without these Chromium silently falls @@ -50,7 +76,7 @@ int RunNebula(HINSTANCE instance, int show_command) { command_line->InitFromString(GetCommandLineW()); std::string initial_url = command_line->GetSwitchValue("url"); - if (nebula::ui::IsEmptyOrChromiumNewTabUrl(initial_url)) { + if (!initial_url.empty() && nebula::ui::IsChromiumNewTabUrl(initial_url)) { initial_url = nebula::ui::GetHomeUrl(); } diff --git a/src/browser/session_state.cpp b/src/browser/session_state.cpp new file mode 100644 index 0000000..92c5463 --- /dev/null +++ b/src/browser/session_state.cpp @@ -0,0 +1,229 @@ +#include "browser/session_state.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "browser/url_utils.h" +#include "ui/paths.h" + +namespace nebula::browser { +namespace { + +constexpr size_t kMaxRestoredTabs = 50; + +std::string ReadFile(const std::filesystem::path& path) { + std::ifstream input(path, std::ios::binary); + if (!input) { + return {}; + } + + std::ostringstream buffer; + buffer << input.rdbuf(); + return buffer.str(); +} + +std::optional ReadUnsignedValue(const std::string& json, std::string_view key) { + const size_t key_pos = json.find(key); + if (key_pos == std::string::npos) { + return std::nullopt; + } + + size_t colon = json.find(':', key_pos + key.size()); + if (colon == std::string::npos) { + return std::nullopt; + } + + ++colon; + while (colon < json.size() && std::isspace(static_cast(json[colon]))) { + ++colon; + } + + size_t end = colon; + while (end < json.size() && std::isdigit(static_cast(json[end]))) { + ++end; + } + + size_t value = 0; + const auto result = std::from_chars(json.data() + colon, json.data() + end, value); + if (result.ec != std::errc{} || result.ptr != json.data() + end) { + return std::nullopt; + } + + return value; +} + +std::optional ReadStringValue(const std::string& object, std::string_view key) { + const size_t key_pos = object.find(key); + if (key_pos == std::string::npos) { + return std::nullopt; + } + + size_t colon = object.find(':', key_pos + key.size()); + if (colon == std::string::npos) { + return std::nullopt; + } + + size_t quote = object.find('"', colon + 1); + if (quote == std::string::npos) { + return std::nullopt; + } + + std::string value; + for (size_t i = quote + 1; i < object.size(); ++i) { + const char ch = object[i]; + if (ch == '"') { + return value; + } + + if (ch != '\\') { + value += ch; + continue; + } + + if (++i >= object.size()) { + return std::nullopt; + } + + switch (object[i]) { + case '"': + case '\\': + case '/': + value += object[i]; + break; + case 'b': + value += '\b'; + break; + case 'f': + value += '\f'; + break; + case 'n': + value += '\n'; + break; + case 'r': + value += '\r'; + break; + case 't': + value += '\t'; + break; + default: + return std::nullopt; + } + } + + return std::nullopt; +} + +std::vector ReadTabs(const std::string& json) { + std::vector tabs; + const size_t tabs_pos = json.find("\"tabs\""); + if (tabs_pos == std::string::npos) { + return tabs; + } + + const size_t array_start = json.find('[', tabs_pos); + const size_t array_end = json.find(']', array_start == std::string::npos ? tabs_pos : array_start); + if (array_start == std::string::npos || array_end == std::string::npos) { + return tabs; + } + + size_t cursor = array_start + 1; + while (cursor < array_end && tabs.size() < kMaxRestoredTabs) { + const size_t object_start = json.find('{', cursor); + if (object_start == std::string::npos || object_start >= array_end) { + break; + } + + const size_t object_end = json.find('}', object_start + 1); + if (object_end == std::string::npos || object_end > array_end) { + break; + } + + const std::string object = json.substr(object_start, object_end - object_start + 1); + const auto url = ReadStringValue(object, "\"url\""); + if (url && !url->empty()) { + PersistedTab tab; + tab.url = *url; + if (const auto title = ReadStringValue(object, "\"title\""); title && !title->empty()) { + tab.title = *title; + } + tabs.push_back(std::move(tab)); + } + + cursor = object_end + 1; + } + + return tabs; +} + +} // namespace + +SessionState LoadSessionState() { + SessionState state; + const std::string json = ReadFile(nebula::ui::GetSessionStatePath()); + if (json.empty()) { + return state; + } + + state.tabs = ReadTabs(json); + if (const auto active_index = ReadUnsignedValue(json, "\"activeTabIndex\"")) { + state.active_tab_index = *active_index; + } + + if (!state.tabs.empty()) { + state.active_tab_index = std::min(state.active_tab_index, state.tabs.size() - 1); + } else { + state.active_tab_index = 0; + } + + return state; +} + +void SaveSessionState(const std::vector& tabs, size_t active_tab_index) { + const auto path = nebula::ui::GetSessionStatePath(); + if (path.empty()) { + return; + } + + std::ostringstream json; + json << "{\n \"activeTabIndex\": " << active_tab_index << ",\n \"tabs\": [\n"; + + bool wrote_tab = false; + for (const auto& tab : tabs) { + if (tab.url.empty()) { + continue; + } + + if (wrote_tab) { + json << ",\n"; + } + + json << " {\"url\": \"" << JsonEscape(tab.url) + << "\", \"title\": \"" << JsonEscape(tab.title) << "\"}"; + wrote_tab = true; + } + + json << "\n ]\n}\n"; + + std::filesystem::path temp_path = path; + temp_path += L".tmp"; + { + std::ofstream output(temp_path, std::ios::binary | std::ios::trunc); + if (!output) { + return; + } + output << json.str(); + } + + std::error_code ec; + std::filesystem::remove(path, ec); + ec.clear(); + std::filesystem::rename(temp_path, path, ec); +} + +} // namespace nebula::browser diff --git a/src/browser/session_state.h b/src/browser/session_state.h new file mode 100644 index 0000000..aff8c7d --- /dev/null +++ b/src/browser/session_state.h @@ -0,0 +1,24 @@ +#pragma once + +#include +#include +#include + +#include "browser/tab.h" + +namespace nebula::browser { + +struct PersistedTab { + std::string url; + std::string title = "New Tab"; +}; + +struct SessionState { + std::vector tabs; + size_t active_tab_index = 0; +}; + +SessionState LoadSessionState(); +void SaveSessionState(const std::vector& tabs, size_t active_tab_index); + +} // namespace nebula::browser diff --git a/src/browser/tab_manager.cpp b/src/browser/tab_manager.cpp index f621723..ccc842f 100644 --- a/src/browser/tab_manager.cpp +++ b/src/browser/tab_manager.cpp @@ -1,5 +1,7 @@ #include "browser/tab_manager.h" +#include + #include "browser/url_utils.h" #include "ui/paths.h" @@ -28,6 +30,28 @@ NebulaTab& TabManager::CreateTab(std::string url) { return tabs_.back(); } +void TabManager::RestoreTabs(const std::vector& tabs, size_t active_tab_index) { + tabs_.clear(); + active_tab_id_ = 0; + + for (const auto& restored_tab : tabs) { + NebulaTab tab; + tab.id = next_tab_id_++; + tab.url = restored_tab.url.empty() ? nebula::ui::GetHomeUrl() : restored_tab.url; + tab.title = restored_tab.title.empty() ? "New Tab" : restored_tab.title; + tabs_.push_back(std::move(tab)); + } + + if (tabs_.empty()) { + CreateInitialTab(nebula::ui::GetHomeUrl()); + return; + } + + active_tab_index = std::min(active_tab_index, tabs_.size() - 1); + active_tab_id_ = tabs_[active_tab_index].id; + Notify(); +} + NebulaTab* TabManager::ActiveTab() { for (auto& tab : tabs_) { if (tab.id == active_tab_id_) { @@ -50,6 +74,15 @@ const std::vector& TabManager::Tabs() const { return tabs_; } +size_t TabManager::ActiveTabIndex() const { + for (size_t i = 0; i < tabs_.size(); ++i) { + if (tabs_[i].id == active_tab_id_) { + return i; + } + } + return 0; +} + bool TabManager::ActivateTab(int tab_id) { if (!FindTab(tab_id)) { return false; diff --git a/src/browser/tab_manager.h b/src/browser/tab_manager.h index 1f0c8a6..fd3f148 100644 --- a/src/browser/tab_manager.h +++ b/src/browser/tab_manager.h @@ -1,9 +1,11 @@ #pragma once +#include #include #include #include "browser/tab.h" +#include "browser/session_state.h" namespace nebula::browser { @@ -19,9 +21,11 @@ public: NebulaTab& CreateInitialTab(std::string initial_url); NebulaTab& CreateTab(std::string url); + void RestoreTabs(const std::vector& tabs, size_t active_tab_index); NebulaTab* ActiveTab(); const NebulaTab* ActiveTab() const; const std::vector& Tabs() const; + size_t ActiveTabIndex() const; bool ActivateTab(int tab_id); CefRefPtr CloseTab(int tab_id); diff --git a/src/ui/paths.cpp b/src/ui/paths.cpp index f9b9102..3cbdea1 100644 --- a/src/ui/paths.cpp +++ b/src/ui/paths.cpp @@ -174,6 +174,11 @@ std::filesystem::path GetCacheDirectory() { return cache; } +std::filesystem::path GetSessionStatePath() { + auto user_data = GetUserDataDirectory(); + return user_data.empty() ? std::filesystem::path{} : user_data / L"session_state.json"; +} + std::filesystem::path GetUiPagePath(const std::wstring& page_name) { const auto exe_dir = GetExecutableDirectory(); if (exe_dir.empty()) { diff --git a/src/ui/paths.h b/src/ui/paths.h index 16e0cfa..9700f3e 100644 --- a/src/ui/paths.h +++ b/src/ui/paths.h @@ -8,6 +8,7 @@ namespace nebula::ui { std::filesystem::path GetExecutableDirectory(); std::filesystem::path GetUserDataDirectory(); std::filesystem::path GetCacheDirectory(); +std::filesystem::path GetSessionStatePath(); std::filesystem::path GetUiPagePath(const std::wstring& page_name); std::string FilePathToUrl(std::filesystem::path path); std::string GetChromeUrl();