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:
2026-05-14 20:48:48 +12:00
parent 406d73c10f
commit 8eb5c1a3b2
10 changed files with 365 additions and 3 deletions
+1
View File
@@ -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
+40 -2
View File
@@ -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;
+1
View File
@@ -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
View File
@@ -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();
}
+229
View File
@@ -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
+24
View File
@@ -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
+33
View File
@@ -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;
+4
View File
@@ -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);
+5
View File
@@ -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()) {
+1
View File
@@ -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();