Persist session state and single-instance
Add session persistence and single-instance handling. Introduces browser/session_state.{h,cpp} to load/save a simple JSON session_state.json (limits restored tabs to 50, basic JSON parsing, atomic write via a .tmp rename). TabManager gains RestoreTabs and ActiveTabIndex to restore and track tabs. NebulaController now calls PersistSession on tab/title/activate/close events, flushes cookies on shutdown, and sets CEF runtime style to Alloy for embedded child browsers and devtools. run.cpp adds a named mutex to prevent multiple instances, enables persistent session cookies, and tweaks initial URL handling. Added GetSessionStatePath() to ui/paths and updated CMakeLists.txt to include the new source file.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -6,9 +6,11 @@
|
||||
#include <charconv>
|
||||
#include <system_error>
|
||||
|
||||
#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<CefBrowser> browser, co
|
||||
nebula::ui::IsChromiumNewTabUrl(url)
|
||||
? nebula::ui::GetHomeUrl()
|
||||
: nebula::ui::ToInternalUrl(url));
|
||||
PersistSession();
|
||||
}
|
||||
|
||||
void NebulaController::OnContentTitleChanged(CefRefPtr<CefBrowser> 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<CefBrowser> 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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
+27
-1
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
#include "browser/session_state.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <charconv>
|
||||
#include <cctype>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <optional>
|
||||
#include <sstream>
|
||||
#include <system_error>
|
||||
|
||||
#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<size_t> 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<unsigned char>(json[colon]))) {
|
||||
++colon;
|
||||
}
|
||||
|
||||
size_t end = colon;
|
||||
while (end < json.size() && std::isdigit(static_cast<unsigned char>(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<std::string> 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<PersistedTab> ReadTabs(const std::string& json) {
|
||||
std::vector<PersistedTab> 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<NebulaTab>& 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
|
||||
@@ -0,0 +1,24 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "browser/tab.h"
|
||||
|
||||
namespace nebula::browser {
|
||||
|
||||
struct PersistedTab {
|
||||
std::string url;
|
||||
std::string title = "New Tab";
|
||||
};
|
||||
|
||||
struct SessionState {
|
||||
std::vector<PersistedTab> tabs;
|
||||
size_t active_tab_index = 0;
|
||||
};
|
||||
|
||||
SessionState LoadSessionState();
|
||||
void SaveSessionState(const std::vector<NebulaTab>& tabs, size_t active_tab_index);
|
||||
|
||||
} // namespace nebula::browser
|
||||
@@ -1,5 +1,7 @@
|
||||
#include "browser/tab_manager.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#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<PersistedTab>& 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<NebulaTab>& 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;
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#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<PersistedTab>& tabs, size_t active_tab_index);
|
||||
NebulaTab* ActiveTab();
|
||||
const NebulaTab* ActiveTab() const;
|
||||
const std::vector<NebulaTab>& Tabs() const;
|
||||
size_t ActiveTabIndex() const;
|
||||
|
||||
bool ActivateTab(int tab_id);
|
||||
CefRefPtr<CefBrowser> CloseTab(int tab_id);
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user