Add NebulaController, tab manager, and CEF clients

Introduce core application structure and browser management: add NebulaController and run entry (src/app/*) to centralize window, tab and CEF lifecycle logic; implement TabManager and NebulaTab (src/browser/*) for tab creation, navigation and state tracking; add URL utilities (NormalizeNavigationInput, JsonEscape) and CEF browser client glue (src/cef/browser_client.cpp/.h) to forward chrome commands and content events. Update app/main.cpp to delegate startup to nebula::app::RunNebula. Add UI assets (chrome.html, chrome.css, chrome.js, lucide, menu-popup updates) and remove obsolete nebot.html. Update CMakeLists to include new sources, add ${CMAKE_SOURCE_DIR}/src to includes and link dwmapi on Windows. Overall this refactors startup and splits responsibilities for cleaner tab and browser lifecycle handling.
This commit is contained in:
Andrew Zambazos
2026-05-14 10:18:51 +12:00
parent 207a849f06
commit a8786b4c1c
23 changed files with 2835 additions and 330 deletions
+11
View File
@@ -39,6 +39,15 @@ add_subdirectory(
set(NEBULA_SOURCES
app/main.cpp
src/app/nebula_controller.cpp
src/app/run.cpp
src/browser/tab.cpp
src/browser/tab_manager.cpp
src/browser/url_utils.cpp
src/cef/browser_client.cpp
src/cef/nebula_app.cpp
src/ui/paths.cpp
src/window/nebula_window.cpp
)
add_executable(NebulaBrowser WIN32
@@ -54,6 +63,7 @@ if(MSVC)
endif()
target_include_directories(NebulaBrowser PRIVATE
"${CMAKE_SOURCE_DIR}/src"
"${CEF_ROOT}"
"${CEF_ROOT}/include"
)
@@ -70,6 +80,7 @@ target_link_libraries(NebulaBrowser PRIVATE
if(WIN32)
target_link_libraries(NebulaBrowser PRIVATE
"${CEF_ROOT}/Release/libcef.lib"
dwmapi
)
target_compile_definitions(NebulaBrowser PRIVATE
+2 -330
View File
@@ -1,333 +1,6 @@
#include <windows.h>
#include <algorithm>
#include <cctype>
#include <filesystem>
#include <string>
#include "include/cef_app.h"
#include "include/cef_browser.h"
#include "include/cef_client.h"
#include "include/cef_command_line.h"
#include "include/cef_request.h"
#include "include/cef_request_handler.h"
#include "include/wrapper/cef_helpers.h"
namespace {
std::string WideToUtf8(const std::wstring& value) {
if (value.empty()) {
return {};
}
const int size = WideCharToMultiByte(
CP_UTF8, 0, value.data(), static_cast<int>(value.size()),
nullptr, 0, nullptr, nullptr);
std::string result(size, '\0');
WideCharToMultiByte(
CP_UTF8, 0, value.data(), static_cast<int>(value.size()),
result.data(), size, nullptr, nullptr);
return result;
}
std::string FilePathToUrl(std::filesystem::path path) {
std::string value = WideToUtf8(path.wstring());
for (char& ch : value) {
if (ch == '\\') {
ch = '/';
}
}
std::string encoded;
encoded.reserve(value.size());
for (char ch : value) {
encoded += ch == ' ' ? "%20" : std::string(1, ch);
}
return "file:///" + encoded;
}
std::filesystem::path GetHomePath() {
wchar_t exe_path[MAX_PATH] = {};
const DWORD length = GetModuleFileNameW(nullptr, exe_path, MAX_PATH);
if (length == 0 || length == MAX_PATH) {
return {};
}
return std::filesystem::path(exe_path).parent_path() /
"ui" / "pages" / "home.html";
}
std::string GetHomeUrl() {
const auto home_path = GetHomePath();
if (home_path.empty()) {
return "https://www.google.com";
}
return FilePathToUrl(home_path);
}
std::string GetUrlWithoutDecoration(std::string url) {
const size_t split = url.find_first_of("?#");
if (split != std::string::npos) {
url.resize(split);
}
return url;
}
std::string ToLowerAscii(std::string value) {
std::transform(value.begin(), value.end(), value.begin(), [](unsigned char ch) {
return static_cast<char>(std::tolower(ch));
});
return value;
}
bool IsInternalHomeUrl(const std::string& url) {
return GetUrlWithoutDecoration(url) == GetHomeUrl();
}
bool IsChromiumNewTabUrl(const std::string& url) {
const std::string target = ToLowerAscii(GetUrlWithoutDecoration(url));
return target == "about:blank" ||
target == "chrome://newtab" ||
target == "chrome://newtab/" ||
target == "chrome://new-tab-page" ||
target == "chrome://new-tab-page/" ||
target == "chrome-search://local-ntp/local-ntp.html";
}
bool IsEmptyOrChromiumNewTabUrl(const std::string& url) {
return url.empty() || IsChromiumNewTabUrl(url);
}
class NebulaClient final : public CefClient,
public CefDisplayHandler,
public CefKeyboardHandler,
public CefLifeSpanHandler,
public CefPermissionHandler,
public CefRequestHandler {
public:
CefRefPtr<CefDisplayHandler> GetDisplayHandler() override {
return this;
}
CefRefPtr<CefKeyboardHandler> GetKeyboardHandler() override {
return this;
}
CefRefPtr<CefLifeSpanHandler> GetLifeSpanHandler() override {
return this;
}
CefRefPtr<CefPermissionHandler> GetPermissionHandler() override {
return this;
}
CefRefPtr<CefRequestHandler> GetRequestHandler() override {
return this;
}
void OnAddressChange(CefRefPtr<CefBrowser> browser,
CefRefPtr<CefFrame> frame,
const CefString& url) override {
CEF_REQUIRE_UI_THREAD();
if (browser && frame && frame->IsMain() &&
IsChromiumNewTabUrl(url)) {
browser->GetMainFrame()->LoadURL(GetHomeUrl());
}
}
void OnTitleChange(CefRefPtr<CefBrowser> browser,
const CefString& title) override {
CEF_REQUIRE_UI_THREAD();
CefWindowHandle window = browser->GetHost()->GetWindowHandle();
if (window) {
SetWindowText(window, std::wstring(title).c_str());
}
}
bool OnPreKeyEvent(CefRefPtr<CefBrowser> browser,
const CefKeyEvent& event,
CefEventHandle os_event,
bool* is_keyboard_shortcut) override {
CEF_REQUIRE_UI_THREAD();
UNREFERENCED_PARAMETER(os_event);
if (event.type == KEYEVENT_RAWKEYDOWN &&
(event.modifiers & EVENTFLAG_CONTROL_DOWN) != 0 &&
event.windows_key_code == 'T') {
if (is_keyboard_shortcut) {
*is_keyboard_shortcut = true;
}
browser->GetMainFrame()->LoadURL(GetHomeUrl());
return true;
}
return false;
}
bool OnBeforePopup(CefRefPtr<CefBrowser> browser,
CefRefPtr<CefFrame> frame,
int popup_id,
const CefString& target_url,
const CefString& target_frame_name,
CefLifeSpanHandler::WindowOpenDisposition target_disposition,
bool user_gesture,
const CefPopupFeatures& popupFeatures,
CefWindowInfo& windowInfo,
CefRefPtr<CefClient>& client,
CefBrowserSettings& settings,
CefRefPtr<CefDictionaryValue>& extra_info,
bool* no_javascript_access) override {
CEF_REQUIRE_UI_THREAD();
UNREFERENCED_PARAMETER(frame);
UNREFERENCED_PARAMETER(popup_id);
UNREFERENCED_PARAMETER(target_frame_name);
UNREFERENCED_PARAMETER(user_gesture);
UNREFERENCED_PARAMETER(popupFeatures);
UNREFERENCED_PARAMETER(windowInfo);
UNREFERENCED_PARAMETER(settings);
UNREFERENCED_PARAMETER(extra_info);
UNREFERENCED_PARAMETER(no_javascript_access);
if (target_disposition == CEF_WOD_NEW_WINDOW &&
IsEmptyOrChromiumNewTabUrl(target_url)) {
client = this;
return false;
}
if (IsChromiumNewTabUrl(target_url)) {
browser->GetMainFrame()->LoadURL(GetHomeUrl());
return true;
}
return false;
}
bool OnBeforeBrowse(CefRefPtr<CefBrowser> browser,
CefRefPtr<CefFrame> frame,
CefRefPtr<CefRequest> request,
bool user_gesture,
bool is_redirect) override {
CEF_REQUIRE_UI_THREAD();
UNREFERENCED_PARAMETER(browser);
UNREFERENCED_PARAMETER(user_gesture);
UNREFERENCED_PARAMETER(is_redirect);
if (frame && frame->IsMain() && request &&
IsChromiumNewTabUrl(request->GetURL())) {
frame->LoadURL(GetHomeUrl());
return true;
}
return false;
}
void OnAfterCreated(CefRefPtr<CefBrowser> browser) override {
CEF_REQUIRE_UI_THREAD();
++browser_count_;
if (browser_count_ > 1 && browser &&
IsEmptyOrChromiumNewTabUrl(browser->GetMainFrame()->GetURL())) {
browser->GetMainFrame()->LoadURL(GetHomeUrl());
}
}
void OnBeforeClose(CefRefPtr<CefBrowser> browser) override {
CEF_REQUIRE_UI_THREAD();
--browser_count_;
if (browser_count_ == 0) {
CefQuitMessageLoop();
}
}
bool OnShowPermissionPrompt(
CefRefPtr<CefBrowser> browser,
uint64_t prompt_id,
const CefString& requesting_origin,
uint32_t requested_permissions,
CefRefPtr<CefPermissionPromptCallback> callback) override {
CEF_REQUIRE_UI_THREAD();
UNREFERENCED_PARAMETER(prompt_id);
UNREFERENCED_PARAMETER(requesting_origin);
if ((requested_permissions & CEF_PERMISSION_TYPE_GEOLOCATION) != 0 &&
browser && callback &&
IsInternalHomeUrl(browser->GetMainFrame()->GetURL())) {
callback->Continue(CEF_PERMISSION_RESULT_ACCEPT);
return true;
}
return false;
}
private:
int browser_count_ = 0;
IMPLEMENT_REFCOUNTING(NebulaClient);
};
class NebulaApp final : public CefApp {
public:
void OnBeforeCommandLineProcessing(
const CefString& process_type,
CefRefPtr<CefCommandLine> command_line) override {
UNREFERENCED_PARAMETER(process_type);
// The bundled UI is loaded from file:// and uses ES modules.
command_line->AppendSwitch("allow-file-access-from-files");
}
private:
IMPLEMENT_REFCOUNTING(NebulaApp);
};
int RunNebula(HINSTANCE instance) {
CefMainArgs main_args(instance);
CefRefPtr<NebulaApp> app(new NebulaApp);
const int subprocess_exit_code =
CefExecuteProcess(main_args, app, nullptr);
if (subprocess_exit_code >= 0) {
return subprocess_exit_code;
}
CefSettings settings;
settings.no_sandbox = true;
if (!CefInitialize(main_args, settings, app, nullptr)) {
return CefGetExitCode();
}
CefRefPtr<CefCommandLine> command_line = CefCommandLine::CreateCommandLine();
command_line->InitFromString(GetCommandLineW());
std::string url = command_line->GetSwitchValue("url");
if (url.empty()) {
url = GetHomeUrl();
}
CefWindowInfo window_info;
window_info.SetAsPopup(nullptr, "Nebula Browser");
CefBrowserSettings browser_settings;
CefRefPtr<NebulaClient> client(new NebulaClient);
if (!CefBrowserHost::CreateBrowser(
window_info, client, url, browser_settings, nullptr, nullptr)) {
CefShutdown();
return 1;
}
CefRunMessageLoop();
CefShutdown();
return 0;
}
} // namespace
#include "app/run.h"
int APIENTRY wWinMain(HINSTANCE instance,
HINSTANCE previous_instance,
@@ -335,7 +8,6 @@ int APIENTRY wWinMain(HINSTANCE instance,
int show_command) {
UNREFERENCED_PARAMETER(previous_instance);
UNREFERENCED_PARAMETER(command_line);
UNREFERENCED_PARAMETER(show_command);
return RunNebula(instance);
return nebula::app::RunNebula(instance, show_command);
}
+526
View File
@@ -0,0 +1,526 @@
#include "app/nebula_controller.h"
#include <windows.h>
#include <algorithm>
#include <charconv>
#include <system_error>
#include "browser/url_utils.h"
#include "include/cef_app.h"
#include "include/cef_browser.h"
#include "include/wrapper/cef_helpers.h"
#include "ui/paths.h"
namespace nebula::app {
namespace {
std::wstring Utf8ToWide(const std::string& value) {
if (value.empty()) {
return {};
}
const int size = MultiByteToWideChar(
CP_UTF8, 0, value.data(), static_cast<int>(value.size()), nullptr, 0);
std::wstring result(size, L'\0');
MultiByteToWideChar(
CP_UTF8, 0, value.data(), static_cast<int>(value.size()), result.data(), size);
return result;
}
CefWindowInfo ChildWindowInfo(HWND parent, const RECT& rect) {
CefWindowInfo info;
info.SetAsChild(
parent,
CefRect(
rect.left,
rect.top,
rect.right - rect.left,
rect.bottom - rect.top));
return info;
}
int ParseTabId(const std::string& value) {
int tab_id = 0;
const auto result = std::from_chars(value.data(), value.data() + value.size(), tab_id);
return result.ec == std::errc{} && result.ptr == value.data() + value.size() ? tab_id : 0;
}
int ScaleForWindow(HWND hwnd, int value) {
return MulDiv(value, static_cast<int>(GetDpiForWindow(hwnd)), 96);
}
RECT MenuPopupRect(HWND hwnd, const nebula::window::BrowserLayout& layout) {
RECT client = {};
GetClientRect(hwnd, &client);
const int width = ScaleForWindow(hwnd, 260);
const int height = ScaleForWindow(hwnd, 218);
const int margin = ScaleForWindow(hwnd, 12);
const int overlap = ScaleForWindow(hwnd, 2);
const LONG x = std::max<LONG>(margin, client.right - width - margin);
const LONG y = std::max<LONG>(0, layout.chrome.bottom - overlap);
return {
x,
y,
std::min<LONG>(client.right, x + width),
std::min<LONG>(client.bottom, y + height),
};
}
void ApplyRoundedWindowRegion(HWND hwnd, int corner_radius) {
RECT rect = {};
if (!hwnd || !GetClientRect(hwnd, &rect)) {
return;
}
HRGN region = CreateRoundRectRgn(
0,
0,
std::max<LONG>(1, rect.right - rect.left) + 1,
std::max<LONG>(1, rect.bottom - rect.top) + 1,
corner_radius,
corner_radius);
if (region && !SetWindowRgn(hwnd, region, TRUE)) {
DeleteObject(region);
}
}
std::string WithCacheBuster(std::string url) {
if (url.empty()) {
return url;
}
const size_t hash = url.find('#');
std::string fragment;
if (hash != std::string::npos) {
fragment = url.substr(hash);
url.resize(hash);
}
const char separator = url.find('?') == std::string::npos ? '?' : '&';
return url + separator + "nebula_cache_bust=" + std::to_string(GetTickCount64()) + fragment;
}
void SetBrowserVisible(CefRefPtr<CefBrowser> browser, bool visible) {
if (!browser) {
return;
}
const HWND hwnd = browser->GetHost()->GetWindowHandle();
if (!hwnd) {
return;
}
ShowWindow(hwnd, visible ? SW_SHOW : SW_HIDE);
if (visible) {
SetWindowPos(hwnd, HWND_TOP, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE);
}
}
} // namespace
NebulaController::NebulaController(HINSTANCE instance, std::string initial_url, int show_command)
: instance_(instance),
initial_url_(std::move(initial_url)),
show_command_(show_command),
tabs_(this) {}
NebulaController::~NebulaController() = default;
bool NebulaController::Create() {
window_ = std::make_unique<nebula::window::NebulaWindow>(this);
return window_->Create(instance_, show_command_);
}
void NebulaController::OnWindowCreated() {
tabs_.CreateInitialTab(initial_url_.empty() ? nebula::ui::GetHomeUrl() : initial_url_);
CreateChromeBrowser();
CreateContentBrowser();
}
void NebulaController::OnWindowResized(const nebula::window::BrowserLayout& layout) {
UNREFERENCED_PARAMETER(layout);
ResizeBrowsers();
}
void NebulaController::OnWindowCloseRequested() {
if (closing_) {
MaybeFinishShutdown();
return;
}
closing_ = true;
if (chrome_browser_) {
chrome_browser_->GetHost()->CloseBrowser(false);
}
if (menu_popup_browser_) {
menu_popup_browser_->GetHost()->CloseBrowser(false);
}
for (const auto& tab : tabs_.Tabs()) {
if (tab.browser) {
tab.browser->GetHost()->CloseBrowser(false);
}
}
MaybeFinishShutdown();
}
void NebulaController::OnActiveTabChanged(const nebula::browser::NebulaTab& tab) {
if (chrome_ready_) {
SendChromeState(tab);
}
}
void NebulaController::OnBrowserCreated(nebula::cef::BrowserRole role, CefRefPtr<CefBrowser> browser) {
if (window_ && browser) {
window_->EnableFrameHitTest(browser->GetHost()->GetWindowHandle());
}
if (role == nebula::cef::BrowserRole::Chrome) {
chrome_browser_ = browser;
chrome_ready_ = true;
if (const auto* tab = tabs_.ActiveTab()) {
SendChromeState(*tab);
}
} else if (role == nebula::cef::BrowserRole::MenuPopup) {
menu_popup_browser_ = browser;
PositionMenuPopup();
} else {
tabs_.SetActiveBrowser(browser);
}
ResizeBrowsers();
}
void NebulaController::OnBrowserClosing(nebula::cef::BrowserRole role, CefRefPtr<CefBrowser> browser) {
if (role == nebula::cef::BrowserRole::Chrome) {
chrome_browser_ = nullptr;
chrome_ready_ = false;
} else if (role == nebula::cef::BrowserRole::MenuPopup) {
menu_popup_browser_ = nullptr;
menu_popup_client_ = nullptr;
} else {
tabs_.ClearBrowser(browser);
}
MaybeFinishShutdown();
}
void NebulaController::OnChromeCommand(const std::string& command, const std::string& payload) {
if (command == "navigate") {
tabs_.LoadURL(payload);
} else if (command == "new-tab") {
CreateNewTab();
} else if (command == "activate-tab") {
ActivateTab(ParseTabId(payload));
} else if (command == "close-tab") {
CloseTab(ParseTabId(payload));
} else if (command == "back") {
tabs_.GoBack();
} else if (command == "forward") {
tabs_.GoForward();
} else if (command == "reload") {
tabs_.Reload();
} else if (command == "stop") {
tabs_.StopLoad();
} else if (command == "settings") {
tabs_.LoadURL(nebula::ui::GetSettingsUrl());
} else if (command == "menu-popup") {
ToggleMenuPopup();
} else if (command == "open-settings") {
CloseMenuPopup();
tabs_.LoadURL(nebula::ui::GetSettingsUrl());
} else if (command == "big-picture") {
CloseMenuPopup();
tabs_.LoadURL(nebula::ui::GetBigPictureUrl());
} else if (command == "toggle-devtools") {
ToggleDevTools();
} else if (command == "zoom-out") {
AdjustZoom(-0.5);
} else if (command == "zoom-in") {
AdjustZoom(0.5);
} else if (command == "hard-reload") {
CloseMenuPopup();
if (auto* tab = tabs_.ActiveTab(); tab && tab->browser) {
tab->browser->ReloadIgnoreCache();
}
} else if (command == "fresh-reload") {
CloseMenuPopup();
FreshReload();
} else if (command == "close-menu-popup") {
CloseMenuPopup();
} else if (command == "home") {
tabs_.LoadURL(nebula::ui::GetHomeUrl());
} else if (command == "minimize" && window_) {
window_->Minimize();
} else if (command == "maximize" && window_) {
window_->ToggleMaximize();
} else if (command == "close" && window_) {
window_->Close();
} else if (command == "drag" && window_) {
window_->BeginDrag();
}
}
void NebulaController::OnContentAddressChanged(CefRefPtr<CefBrowser> browser, const std::string& url) {
tabs_.UpdateURL(browser, nebula::ui::IsChromiumNewTabUrl(url) ? nebula::ui::GetHomeUrl() : url);
}
void NebulaController::OnContentTitleChanged(CefRefPtr<CefBrowser> browser, const std::string& title) {
tabs_.UpdateTitle(browser, title);
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"));
}
}
void NebulaController::OnContentLoadingStateChanged(CefRefPtr<CefBrowser> browser, bool is_loading) {
tabs_.UpdateLoadingState(browser, is_loading);
}
void NebulaController::OnContentLoadProgressChanged(CefRefPtr<CefBrowser> browser, double progress) {
tabs_.UpdateLoadProgress(browser, progress);
}
void NebulaController::OnContentFaviconChanged(CefRefPtr<CefBrowser> browser, const std::vector<std::string>& urls) {
tabs_.UpdateFavicon(browser, urls);
}
void NebulaController::OnPopupRequested(CefRefPtr<CefBrowser> browser, const std::string& target_url) {
if (!tabs_.OwnsBrowser(browser)) {
return;
}
tabs_.LoadURL(nebula::ui::IsEmptyOrChromiumNewTabUrl(target_url)
? nebula::ui::GetHomeUrl()
: target_url);
}
void NebulaController::CreateNewTab() {
if (auto* tab = tabs_.ActiveTab()) {
SetBrowserVisible(tab->browser, false);
}
tabs_.CreateTab(nebula::ui::GetHomeUrl());
CreateContentBrowser();
}
void NebulaController::ActivateTab(int tab_id) {
auto* current_tab = tabs_.ActiveTab();
if (current_tab && current_tab->id == tab_id) {
return;
}
CefRefPtr<CefBrowser> previous_browser = current_tab ? current_tab->browser : nullptr;
if (!tabs_.ActivateTab(tab_id)) {
return;
}
SetBrowserVisible(previous_browser, false);
if (auto* active_tab = tabs_.ActiveTab()) {
if (active_tab->browser) {
SetBrowserVisible(active_tab->browser, true);
} else {
CreateContentBrowser();
}
}
ResizeBrowsers();
}
void NebulaController::CloseTab(int tab_id) {
const bool was_active = [this, tab_id] {
const auto* tab = tabs_.ActiveTab();
return tab && tab->id == tab_id;
}();
CefRefPtr<CefBrowser> closing_browser = tabs_.CloseTab(tab_id);
if (closing_browser) {
closing_browser->GetHost()->CloseBrowser(false);
}
if (!tabs_.ActiveTab()) {
tabs_.CreateTab(nebula::ui::GetHomeUrl());
CreateContentBrowser();
return;
}
if (was_active) {
if (auto* active_tab = tabs_.ActiveTab()) {
SetBrowserVisible(active_tab->browser, true);
}
ResizeBrowsers();
}
}
void NebulaController::CreateChromeBrowser() {
if (!window_ || !window_->hwnd()) {
return;
}
const auto layout = window_->CurrentLayout();
CefBrowserSettings browser_settings;
chrome_client_ = new nebula::cef::NebulaBrowserClient(nebula::cef::BrowserRole::Chrome, this);
CefWindowInfo window_info = ChildWindowInfo(window_->hwnd(), layout.chrome);
CefBrowserHost::CreateBrowser(
window_info, chrome_client_, nebula::ui::GetChromeUrl(), browser_settings, nullptr, nullptr);
}
void NebulaController::CreateContentBrowser() {
if (!window_ || !window_->hwnd()) {
return;
}
const auto* tab = tabs_.ActiveTab();
const std::string url = tab && !tab->url.empty() ? tab->url : nebula::ui::GetHomeUrl();
const auto layout = window_->CurrentLayout();
CefBrowserSettings browser_settings;
content_client_ = new nebula::cef::NebulaBrowserClient(nebula::cef::BrowserRole::Content, this);
CefWindowInfo window_info = ChildWindowInfo(window_->hwnd(), layout.content);
CefBrowserHost::CreateBrowser(window_info, content_client_, url, browser_settings, nullptr, nullptr);
}
void NebulaController::ToggleMenuPopup() {
if (menu_popup_browser_) {
CloseMenuPopup();
return;
}
CreateMenuPopupBrowser();
}
void NebulaController::CloseMenuPopup() {
if (menu_popup_browser_) {
menu_popup_browser_->GetHost()->CloseBrowser(false);
}
}
void NebulaController::CreateMenuPopupBrowser() {
if (!window_ || !window_->hwnd()) {
return;
}
const auto layout = window_->CurrentLayout();
CefBrowserSettings browser_settings;
menu_popup_client_ = new nebula::cef::NebulaBrowserClient(nebula::cef::BrowserRole::MenuPopup, this);
CefWindowInfo window_info = ChildWindowInfo(window_->hwnd(), MenuPopupRect(window_->hwnd(), layout));
CefBrowserHost::CreateBrowser(
window_info, menu_popup_client_, nebula::ui::GetMenuPopupUrl(), browser_settings, nullptr, nullptr);
}
void NebulaController::PositionMenuPopup() {
if (!window_ || !window_->hwnd() || !menu_popup_browser_) {
return;
}
const auto rect = MenuPopupRect(window_->hwnd(), window_->CurrentLayout());
const HWND hwnd = menu_popup_browser_->GetHost()->GetWindowHandle();
window_->ResizeChild(hwnd, rect);
ApplyRoundedWindowRegion(hwnd, ScaleForWindow(window_->hwnd(), 28));
SetWindowPos(hwnd, HWND_TOP, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE);
}
void NebulaController::ToggleDevTools() {
auto* tab = tabs_.ActiveTab();
if (!tab || !tab->browser || !window_ || !window_->hwnd()) {
return;
}
CefRefPtr<CefBrowserHost> host = tab->browser->GetHost();
if (host->HasDevTools()) {
host->CloseDevTools();
return;
}
CefWindowInfo window_info;
window_info.SetAsPopup(window_->hwnd(), "Nebula Developer Tools");
CefBrowserSettings browser_settings;
host->ShowDevTools(window_info, content_client_, browser_settings, CefPoint());
}
void NebulaController::AdjustZoom(double delta) {
auto* tab = tabs_.ActiveTab();
if (!tab || !tab->browser) {
return;
}
CefRefPtr<CefBrowserHost> host = tab->browser->GetHost();
host->SetZoomLevel(host->GetZoomLevel() + delta);
}
void NebulaController::FreshReload() {
auto* tab = tabs_.ActiveTab();
if (!tab || tab->url.empty()) {
return;
}
tabs_.LoadURL(WithCacheBuster(tab->url));
}
void NebulaController::ResizeBrowsers() {
if (!window_) {
return;
}
const auto layout = window_->CurrentLayout();
if (chrome_browser_) {
window_->ResizeChild(chrome_browser_->GetHost()->GetWindowHandle(), layout.chrome);
}
if (const auto* tab = tabs_.ActiveTab(); tab && tab->browser) {
window_->ResizeChild(tab->browser->GetHost()->GetWindowHandle(), layout.content);
}
PositionMenuPopup();
}
void NebulaController::SendChromeState(const nebula::browser::NebulaTab& tab) {
if (!chrome_browser_) {
return;
}
std::string tabs_json = "[";
const auto& tabs = tabs_.Tabs();
for (size_t i = 0; i < tabs.size(); ++i) {
const auto& item = tabs[i];
if (i > 0) {
tabs_json += ",";
}
tabs_json +=
"{\"id\":" + std::to_string(item.id) +
",\"title\":\"" + nebula::browser::JsonEscape(item.title) + "\"" +
",\"isLoading\":" + std::string(item.is_loading ? "true" : "false") +
",\"favicon\":\"" + nebula::browser::JsonEscape(item.favicon_url) + "\"" +
"}";
}
tabs_json += "]";
const std::string script =
"window.NebulaChrome && window.NebulaChrome.applyState({"
"\"id\":" + std::to_string(tab.id) +
",\"url\":\"" + nebula::browser::JsonEscape(tab.url) + "\""
",\"title\":\"" + nebula::browser::JsonEscape(tab.title) + "\""
",\"isLoading\":" + std::string(tab.is_loading ? "true" : "false") +
",\"progress\":" + std::to_string(tab.load_progress) +
",\"canGoBack\":" + std::string(tab.CanGoBack() ? "true" : "false") +
",\"canGoForward\":" + std::string(tab.CanGoForward() ? "true" : "false") +
",\"favicon\":\"" + nebula::browser::JsonEscape(tab.favicon_url) + "\"" +
",\"tabs\":" + tabs_json +
"});";
chrome_browser_->GetMainFrame()->ExecuteJavaScript(script, nebula::ui::GetChromeUrl(), 0);
}
void NebulaController::MaybeFinishShutdown() {
if (!closing_) {
return;
}
if (chrome_browser_ || menu_popup_browser_ || tabs_.HasOpenBrowsers()) {
return;
}
if (window_ && window_->hwnd()) {
DestroyWindow(window_->hwnd());
}
CefQuitMessageLoop();
}
} // namespace nebula::app
+70
View File
@@ -0,0 +1,70 @@
#pragma once
#include <memory>
#include <string>
#include <vector>
#include "browser/tab_manager.h"
#include "cef/browser_client.h"
#include "window/nebula_window.h"
namespace nebula::app {
class NebulaController final : public nebula::window::WindowDelegate,
public nebula::browser::TabObserver,
public nebula::cef::BrowserClientDelegate {
public:
NebulaController(HINSTANCE instance, std::string initial_url, int show_command);
~NebulaController() override;
bool Create();
void OnWindowCreated() override;
void OnWindowResized(const nebula::window::BrowserLayout& layout) override;
void OnWindowCloseRequested() override;
void OnActiveTabChanged(const nebula::browser::NebulaTab& tab) override;
void OnBrowserCreated(nebula::cef::BrowserRole role, CefRefPtr<CefBrowser> browser) override;
void OnBrowserClosing(nebula::cef::BrowserRole role, CefRefPtr<CefBrowser> browser) override;
void OnChromeCommand(const std::string& command, const std::string& payload) override;
void OnContentAddressChanged(CefRefPtr<CefBrowser> browser, const std::string& url) override;
void OnContentTitleChanged(CefRefPtr<CefBrowser> browser, const std::string& title) override;
void OnContentLoadingStateChanged(CefRefPtr<CefBrowser> browser, bool is_loading) override;
void OnContentLoadProgressChanged(CefRefPtr<CefBrowser> browser, double progress) override;
void OnContentFaviconChanged(CefRefPtr<CefBrowser> browser, const std::vector<std::string>& urls) override;
void OnPopupRequested(CefRefPtr<CefBrowser> browser, const std::string& target_url) override;
private:
void CreateNewTab();
void ActivateTab(int tab_id);
void CloseTab(int tab_id);
void CreateChromeBrowser();
void CreateContentBrowser();
void ToggleMenuPopup();
void CloseMenuPopup();
void CreateMenuPopupBrowser();
void PositionMenuPopup();
void ToggleDevTools();
void AdjustZoom(double delta);
void FreshReload();
void ResizeBrowsers();
void SendChromeState(const nebula::browser::NebulaTab& tab);
void MaybeFinishShutdown();
HINSTANCE instance_ = nullptr;
std::string initial_url_;
int show_command_ = SW_SHOWDEFAULT;
bool closing_ = false;
bool chrome_ready_ = false;
std::unique_ptr<nebula::window::NebulaWindow> window_;
nebula::browser::TabManager tabs_;
CefRefPtr<CefBrowser> chrome_browser_;
CefRefPtr<CefBrowser> menu_popup_browser_;
CefRefPtr<nebula::cef::NebulaBrowserClient> chrome_client_;
CefRefPtr<nebula::cef::NebulaBrowserClient> content_client_;
CefRefPtr<nebula::cef::NebulaBrowserClient> menu_popup_client_;
};
} // namespace nebula::app
+54
View File
@@ -0,0 +1,54 @@
#include "app/run.h"
#include "app/nebula_controller.h"
#include "cef/nebula_app.h"
#include "include/cef_app.h"
#include "include/cef_command_line.h"
#include "ui/paths.h"
namespace nebula::app {
namespace {
void EnableDpiAwareness() {
SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2);
}
} // namespace
int RunNebula(HINSTANCE instance, int show_command) {
EnableDpiAwareness();
CefMainArgs main_args(instance);
CefRefPtr<nebula::cef::NebulaApp> app(new nebula::cef::NebulaApp);
const int subprocess_exit_code = CefExecuteProcess(main_args, app, nullptr);
if (subprocess_exit_code >= 0) {
return subprocess_exit_code;
}
CefSettings settings;
settings.no_sandbox = true;
if (!CefInitialize(main_args, settings, app, nullptr)) {
return CefGetExitCode();
}
CefRefPtr<CefCommandLine> command_line = CefCommandLine::CreateCommandLine();
command_line->InitFromString(GetCommandLineW());
std::string initial_url = command_line->GetSwitchValue("url");
if (nebula::ui::IsEmptyOrChromiumNewTabUrl(initial_url)) {
initial_url = nebula::ui::GetHomeUrl();
}
NebulaController controller(instance, initial_url, show_command);
const bool created = controller.Create();
if (created) {
CefRunMessageLoop();
}
CefShutdown();
return created ? 0 : 1;
}
} // namespace nebula::app
+9
View File
@@ -0,0 +1,9 @@
#pragma once
#include <windows.h>
namespace nebula::app {
int RunNebula(HINSTANCE instance, int show_command);
} // namespace nebula::app
+13
View File
@@ -0,0 +1,13 @@
#include "browser/tab.h"
namespace nebula::browser {
bool NebulaTab::CanGoBack() const {
return browser && browser->CanGoBack();
}
bool NebulaTab::CanGoForward() const {
return browser && browser->CanGoForward();
}
} // namespace nebula::browser
+23
View File
@@ -0,0 +1,23 @@
#pragma once
#include <string>
#include <vector>
#include "include/cef_browser.h"
namespace nebula::browser {
struct NebulaTab {
int id = 1;
std::string url;
std::string title = "New Tab";
bool is_loading = false;
double load_progress = 0.0;
std::string favicon_url;
CefRefPtr<CefBrowser> browser;
bool CanGoBack() const;
bool CanGoForward() const;
};
} // namespace nebula::browser
+239
View File
@@ -0,0 +1,239 @@
#include "browser/tab_manager.h"
#include "browser/url_utils.h"
namespace nebula::browser {
TabManager::TabManager(TabObserver* observer) : observer_(observer) {}
NebulaTab& TabManager::CreateInitialTab(std::string initial_url) {
tabs_.clear();
NebulaTab tab;
tab.id = next_tab_id_++;
tab.url = std::move(initial_url);
tabs_.push_back(std::move(tab));
active_tab_id_ = tabs_.front().id;
Notify();
return tabs_.front();
}
NebulaTab& TabManager::CreateTab(std::string url) {
NebulaTab tab;
tab.id = next_tab_id_++;
tab.url = std::move(url);
tabs_.push_back(std::move(tab));
active_tab_id_ = tabs_.back().id;
Notify();
return tabs_.back();
}
NebulaTab* TabManager::ActiveTab() {
for (auto& tab : tabs_) {
if (tab.id == active_tab_id_) {
return &tab;
}
}
return nullptr;
}
const NebulaTab* TabManager::ActiveTab() const {
for (const auto& tab : tabs_) {
if (tab.id == active_tab_id_) {
return &tab;
}
}
return nullptr;
}
const std::vector<NebulaTab>& TabManager::Tabs() const {
return tabs_;
}
bool TabManager::ActivateTab(int tab_id) {
if (!FindTab(tab_id)) {
return false;
}
active_tab_id_ = tab_id;
Notify();
return true;
}
CefRefPtr<CefBrowser> TabManager::CloseTab(int tab_id) {
for (auto it = tabs_.begin(); it != tabs_.end(); ++it) {
if (it->id != tab_id) {
continue;
}
CefRefPtr<CefBrowser> browser = it->browser;
const bool was_active = it->id == active_tab_id_;
const auto next_it = tabs_.erase(it);
if (tabs_.empty()) {
active_tab_id_ = 0;
} else if (was_active) {
active_tab_id_ = next_it != tabs_.end() ? next_it->id : tabs_.back().id;
}
Notify();
return browser;
}
return nullptr;
}
void TabManager::SetActiveBrowser(CefRefPtr<CefBrowser> browser) {
if (NebulaTab* tab = ActiveTab()) {
tab->browser = browser;
if (browser && tab->url.empty()) {
tab->url = browser->GetMainFrame()->GetURL();
}
Notify();
}
}
bool TabManager::OwnsBrowser(CefRefPtr<CefBrowser> browser) const {
if (!browser) {
return false;
}
for (const auto& tab : tabs_) {
if (tab.browser && tab.browser->IsSame(browser)) {
return true;
}
}
return false;
}
bool TabManager::HasOpenBrowsers() const {
for (const auto& tab : tabs_) {
if (tab.browser) {
return true;
}
}
return false;
}
void TabManager::ClearBrowser(CefRefPtr<CefBrowser> browser) {
if (NebulaTab* tab = FindTab(browser)) {
tab->browser = nullptr;
Notify();
}
}
void TabManager::LoadURL(const std::string& input) {
NebulaTab* tab = ActiveTab();
if (!tab || !tab->browser) {
return;
}
const std::string target = NormalizeNavigationInput(input);
if (target.empty()) {
return;
}
tab->url = target;
tab->favicon_url.clear();
tab->browser->GetMainFrame()->LoadURL(target);
Notify();
}
void TabManager::GoBack() {
NebulaTab* tab = ActiveTab();
if (tab && tab->browser && tab->browser->CanGoBack()) {
tab->browser->GoBack();
}
}
void TabManager::GoForward() {
NebulaTab* tab = ActiveTab();
if (tab && tab->browser && tab->browser->CanGoForward()) {
tab->browser->GoForward();
}
}
void TabManager::Reload() {
NebulaTab* tab = ActiveTab();
if (tab && tab->browser) {
tab->browser->Reload();
}
}
void TabManager::StopLoad() {
NebulaTab* tab = ActiveTab();
if (tab && tab->browser) {
tab->browser->StopLoad();
}
}
void TabManager::UpdateURL(CefRefPtr<CefBrowser> browser, std::string url) {
if (NebulaTab* tab = FindTab(browser)) {
tab->url = std::move(url);
Notify();
}
}
void TabManager::UpdateTitle(CefRefPtr<CefBrowser> browser, std::string title) {
if (NebulaTab* tab = FindTab(browser)) {
tab->title = title.empty() ? "New Tab" : std::move(title);
Notify();
}
}
void TabManager::UpdateLoadingState(CefRefPtr<CefBrowser> browser, bool is_loading) {
if (NebulaTab* tab = FindTab(browser)) {
tab->is_loading = is_loading;
if (is_loading) {
tab->favicon_url.clear();
}
if (!is_loading) {
tab->load_progress = 1.0;
}
Notify();
}
}
void TabManager::UpdateLoadProgress(CefRefPtr<CefBrowser> browser, double progress) {
if (NebulaTab* tab = FindTab(browser)) {
tab->load_progress = progress;
Notify();
}
}
void TabManager::UpdateFavicon(CefRefPtr<CefBrowser> browser, const std::vector<std::string>& urls) {
if (NebulaTab* tab = FindTab(browser)) {
tab->favicon_url = urls.empty() ? std::string{} : urls.front();
Notify();
}
}
void TabManager::Notify() {
const NebulaTab* tab = ActiveTab();
if (observer_ && tab) {
observer_->OnActiveTabChanged(*tab);
}
}
NebulaTab* TabManager::FindTab(int tab_id) {
for (auto& tab : tabs_) {
if (tab.id == tab_id) {
return &tab;
}
}
return nullptr;
}
NebulaTab* TabManager::FindTab(CefRefPtr<CefBrowser> browser) {
if (!browser) {
return nullptr;
}
for (auto& tab : tabs_) {
if (tab.browser && tab.browser->IsSame(browser)) {
return &tab;
}
}
return nullptr;
}
} // namespace nebula::browser
+56
View File
@@ -0,0 +1,56 @@
#pragma once
#include <string>
#include <vector>
#include "browser/tab.h"
namespace nebula::browser {
class TabObserver {
public:
virtual ~TabObserver() = default;
virtual void OnActiveTabChanged(const NebulaTab& tab) = 0;
};
class TabManager {
public:
explicit TabManager(TabObserver* observer);
NebulaTab& CreateInitialTab(std::string initial_url);
NebulaTab& CreateTab(std::string url);
NebulaTab* ActiveTab();
const NebulaTab* ActiveTab() const;
const std::vector<NebulaTab>& Tabs() const;
bool ActivateTab(int tab_id);
CefRefPtr<CefBrowser> CloseTab(int tab_id);
void SetActiveBrowser(CefRefPtr<CefBrowser> browser);
bool OwnsBrowser(CefRefPtr<CefBrowser> browser) const;
void ClearBrowser(CefRefPtr<CefBrowser> browser);
bool HasOpenBrowsers() const;
void LoadURL(const std::string& input);
void GoBack();
void GoForward();
void Reload();
void StopLoad();
void UpdateURL(CefRefPtr<CefBrowser> browser, std::string url);
void UpdateTitle(CefRefPtr<CefBrowser> browser, std::string title);
void UpdateLoadingState(CefRefPtr<CefBrowser> browser, bool is_loading);
void UpdateLoadProgress(CefRefPtr<CefBrowser> browser, double progress);
void UpdateFavicon(CefRefPtr<CefBrowser> browser, const std::vector<std::string>& urls);
private:
void Notify();
NebulaTab* FindTab(int tab_id);
NebulaTab* FindTab(CefRefPtr<CefBrowser> browser);
TabObserver* observer_ = nullptr;
std::vector<NebulaTab> tabs_;
int active_tab_id_ = 0;
int next_tab_id_ = 1;
};
} // namespace nebula::browser
+110
View File
@@ -0,0 +1,110 @@
#include "browser/url_utils.h"
#include <cctype>
#include <iomanip>
#include <sstream>
namespace nebula::browser {
namespace {
constexpr char kSearchUrl[] = "https://www.google.com/search?q=";
std::string Trim(std::string value) {
while (!value.empty() && std::isspace(static_cast<unsigned char>(value.front()))) {
value.erase(value.begin());
}
while (!value.empty() && std::isspace(static_cast<unsigned char>(value.back()))) {
value.pop_back();
}
return value;
}
bool StartsWithScheme(const std::string& value) {
return value.starts_with("http://") ||
value.starts_with("https://") ||
value.starts_with("file:") ||
value.starts_with("data:") ||
value.starts_with("blob:") ||
value.starts_with("chrome:");
}
bool LooksLikeHostName(const std::string& value) {
return value.find('.') != std::string::npos &&
value.find_first_of(" \t\r\n") == std::string::npos;
}
std::string UrlEncodeSearch(const std::string& value) {
std::ostringstream encoded;
encoded << std::hex << std::uppercase;
for (unsigned char ch : value) {
if (std::isalnum(ch) || ch == '-' || ch == '_' || ch == '.' || ch == '~') {
encoded << static_cast<char>(ch);
} else if (ch == ' ') {
encoded << '+';
} else {
encoded << '%' << std::setw(2) << std::setfill('0') << static_cast<int>(ch);
}
}
return encoded.str();
}
} // namespace
std::string NormalizeNavigationInput(const std::string& input) {
const std::string value = Trim(input);
if (value.empty()) {
return {};
}
if (StartsWithScheme(value)) {
return value;
}
if (LooksLikeHostName(value)) {
return "https://" + value;
}
return std::string(kSearchUrl) + UrlEncodeSearch(value);
}
std::string JsonEscape(const std::string& value) {
std::ostringstream escaped;
for (unsigned char ch : value) {
switch (ch) {
case '\\':
escaped << "\\\\";
break;
case '"':
escaped << "\\\"";
break;
case '\b':
escaped << "\\b";
break;
case '\f':
escaped << "\\f";
break;
case '\n':
escaped << "\\n";
break;
case '\r':
escaped << "\\r";
break;
case '\t':
escaped << "\\t";
break;
default:
if (ch < 0x20) {
escaped << "\\u" << std::hex << std::uppercase << std::setw(4)
<< std::setfill('0') << static_cast<int>(ch);
} else {
escaped << static_cast<char>(ch);
}
break;
}
}
return escaped.str();
}
} // namespace nebula::browser
+10
View File
@@ -0,0 +1,10 @@
#pragma once
#include <string>
namespace nebula::browser {
std::string NormalizeNavigationInput(const std::string& input);
std::string JsonEscape(const std::string& value);
} // namespace nebula::browser
+222
View File
@@ -0,0 +1,222 @@
#include "cef/browser_client.h"
#include "include/cef_request.h"
#include "include/wrapper/cef_helpers.h"
#include "ui/paths.h"
namespace nebula::cef {
namespace {
constexpr char kChromeCommandMessage[] = "NebulaChromeCommand";
std::vector<std::string> ToStringVector(const std::vector<CefString>& values) {
std::vector<std::string> result;
result.reserve(values.size());
for (const auto& value : values) {
result.push_back(value.ToString());
}
return result;
}
} // namespace
NebulaBrowserClient::NebulaBrowserClient(BrowserRole role, BrowserClientDelegate* delegate)
: role_(role), delegate_(delegate) {}
bool NebulaBrowserClient::OnProcessMessageReceived(CefRefPtr<CefBrowser> browser,
CefRefPtr<CefFrame> frame,
CefProcessId source_process,
CefRefPtr<CefProcessMessage> message) {
CEF_REQUIRE_UI_THREAD();
UNREFERENCED_PARAMETER(browser);
UNREFERENCED_PARAMETER(frame);
UNREFERENCED_PARAMETER(source_process);
if ((role_ != BrowserRole::Chrome && role_ != BrowserRole::MenuPopup) || !message ||
message->GetName().ToString() != kChromeCommandMessage) {
return false;
}
CefRefPtr<CefListValue> args = message->GetArgumentList();
const std::string command = args && args->GetSize() > 0 ? args->GetString(0).ToString() : "";
const std::string payload = args && args->GetSize() > 1 ? args->GetString(1).ToString() : "";
if (delegate_ && !command.empty()) {
delegate_->OnChromeCommand(command, payload);
return true;
}
return false;
}
void NebulaBrowserClient::OnAddressChange(CefRefPtr<CefBrowser> browser,
CefRefPtr<CefFrame> frame,
const CefString& url) {
CEF_REQUIRE_UI_THREAD();
if (role_ == BrowserRole::Content && delegate_ && frame && frame->IsMain()) {
delegate_->OnContentAddressChanged(browser, url.ToString());
}
}
void NebulaBrowserClient::OnTitleChange(CefRefPtr<CefBrowser> browser,
const CefString& title) {
CEF_REQUIRE_UI_THREAD();
if (role_ == BrowserRole::Content && delegate_) {
delegate_->OnContentTitleChanged(browser, title.ToString());
}
}
void NebulaBrowserClient::OnFaviconURLChange(CefRefPtr<CefBrowser> browser,
const std::vector<CefString>& icon_urls) {
CEF_REQUIRE_UI_THREAD();
if (role_ == BrowserRole::Content && delegate_) {
delegate_->OnContentFaviconChanged(browser, ToStringVector(icon_urls));
}
}
bool NebulaBrowserClient::OnPreKeyEvent(CefRefPtr<CefBrowser> browser,
const CefKeyEvent& event,
CefEventHandle os_event,
bool* is_keyboard_shortcut) {
CEF_REQUIRE_UI_THREAD();
UNREFERENCED_PARAMETER(os_event);
if (role_ == BrowserRole::Content &&
event.type == KEYEVENT_RAWKEYDOWN &&
(event.modifiers & EVENTFLAG_CONTROL_DOWN) != 0 &&
event.windows_key_code == 'T') {
if (is_keyboard_shortcut) {
*is_keyboard_shortcut = true;
}
if (delegate_) {
delegate_->OnPopupRequested(browser, nebula::ui::GetHomeUrl());
}
return true;
}
return false;
}
bool NebulaBrowserClient::OnBeforePopup(CefRefPtr<CefBrowser> browser,
CefRefPtr<CefFrame> frame,
int popup_id,
const CefString& target_url,
const CefString& target_frame_name,
CefLifeSpanHandler::WindowOpenDisposition target_disposition,
bool user_gesture,
const CefPopupFeatures& popupFeatures,
CefWindowInfo& windowInfo,
CefRefPtr<CefClient>& client,
CefBrowserSettings& settings,
CefRefPtr<CefDictionaryValue>& extra_info,
bool* no_javascript_access) {
CEF_REQUIRE_UI_THREAD();
UNREFERENCED_PARAMETER(frame);
UNREFERENCED_PARAMETER(popup_id);
UNREFERENCED_PARAMETER(target_frame_name);
UNREFERENCED_PARAMETER(target_disposition);
UNREFERENCED_PARAMETER(user_gesture);
UNREFERENCED_PARAMETER(popupFeatures);
UNREFERENCED_PARAMETER(windowInfo);
UNREFERENCED_PARAMETER(client);
UNREFERENCED_PARAMETER(settings);
UNREFERENCED_PARAMETER(extra_info);
UNREFERENCED_PARAMETER(no_javascript_access);
if (role_ == BrowserRole::Content && delegate_) {
delegate_->OnPopupRequested(browser, target_url.ToString());
return true;
}
return false;
}
void NebulaBrowserClient::OnAfterCreated(CefRefPtr<CefBrowser> browser) {
CEF_REQUIRE_UI_THREAD();
if (delegate_) {
delegate_->OnBrowserCreated(role_, browser);
}
}
void NebulaBrowserClient::OnBeforeClose(CefRefPtr<CefBrowser> browser) {
CEF_REQUIRE_UI_THREAD();
if (delegate_) {
delegate_->OnBrowserClosing(role_, browser);
}
}
void NebulaBrowserClient::OnLoadingStateChange(CefRefPtr<CefBrowser> browser,
bool isLoading,
bool canGoBack,
bool canGoForward) {
CEF_REQUIRE_UI_THREAD();
UNREFERENCED_PARAMETER(canGoBack);
UNREFERENCED_PARAMETER(canGoForward);
if (role_ == BrowserRole::Content && delegate_) {
delegate_->OnContentLoadingStateChanged(browser, isLoading);
}
}
void NebulaBrowserClient::OnLoadStart(CefRefPtr<CefBrowser> browser,
CefRefPtr<CefFrame> frame,
TransitionType transition_type) {
CEF_REQUIRE_UI_THREAD();
UNREFERENCED_PARAMETER(transition_type);
if (role_ == BrowserRole::Content && delegate_ && frame && frame->IsMain()) {
delegate_->OnContentLoadProgressChanged(browser, 0.12);
}
}
void NebulaBrowserClient::OnLoadEnd(CefRefPtr<CefBrowser> browser,
CefRefPtr<CefFrame> frame,
int httpStatusCode) {
CEF_REQUIRE_UI_THREAD();
UNREFERENCED_PARAMETER(httpStatusCode);
if (role_ == BrowserRole::Content && delegate_ && frame && frame->IsMain()) {
delegate_->OnContentLoadProgressChanged(browser, 1.0);
}
}
bool NebulaBrowserClient::OnBeforeBrowse(CefRefPtr<CefBrowser> browser,
CefRefPtr<CefFrame> frame,
CefRefPtr<CefRequest> request,
bool user_gesture,
bool is_redirect) {
CEF_REQUIRE_UI_THREAD();
UNREFERENCED_PARAMETER(browser);
UNREFERENCED_PARAMETER(user_gesture);
UNREFERENCED_PARAMETER(is_redirect);
if (role_ == BrowserRole::Content && frame && frame->IsMain() && request &&
nebula::ui::IsChromiumNewTabUrl(request->GetURL().ToString())) {
frame->LoadURL(nebula::ui::GetHomeUrl());
return true;
}
return false;
}
bool NebulaBrowserClient::OnShowPermissionPrompt(
CefRefPtr<CefBrowser> browser,
uint64_t prompt_id,
const CefString& requesting_origin,
uint32_t requested_permissions,
CefRefPtr<CefPermissionPromptCallback> callback) {
CEF_REQUIRE_UI_THREAD();
UNREFERENCED_PARAMETER(prompt_id);
UNREFERENCED_PARAMETER(requesting_origin);
if (role_ == BrowserRole::Content &&
(requested_permissions & CEF_PERMISSION_TYPE_GEOLOCATION) != 0 &&
browser && callback &&
nebula::ui::IsInternalHomeUrl(browser->GetMainFrame()->GetURL().ToString())) {
callback->Continue(CEF_PERMISSION_RESULT_ACCEPT);
return true;
}
return false;
}
} // namespace nebula::cef
+118
View File
@@ -0,0 +1,118 @@
#pragma once
#include <string>
#include <vector>
#include "include/cef_client.h"
#include "include/cef_display_handler.h"
#include "include/cef_keyboard_handler.h"
#include "include/cef_life_span_handler.h"
#include "include/cef_load_handler.h"
#include "include/cef_permission_handler.h"
#include "include/cef_request_handler.h"
namespace nebula::cef {
enum class BrowserRole {
Chrome,
Content,
MenuPopup,
};
class BrowserClientDelegate {
public:
virtual ~BrowserClientDelegate() = default;
virtual void OnBrowserCreated(BrowserRole role, CefRefPtr<CefBrowser> browser) = 0;
virtual void OnBrowserClosing(BrowserRole role, CefRefPtr<CefBrowser> browser) = 0;
virtual void OnChromeCommand(const std::string& command, const std::string& payload) = 0;
virtual void OnContentAddressChanged(CefRefPtr<CefBrowser> browser, const std::string& url) = 0;
virtual void OnContentTitleChanged(CefRefPtr<CefBrowser> browser, const std::string& title) = 0;
virtual void OnContentLoadingStateChanged(CefRefPtr<CefBrowser> browser, bool is_loading) = 0;
virtual void OnContentLoadProgressChanged(CefRefPtr<CefBrowser> browser, double progress) = 0;
virtual void OnContentFaviconChanged(CefRefPtr<CefBrowser> browser, const std::vector<std::string>& urls) = 0;
virtual void OnPopupRequested(CefRefPtr<CefBrowser> browser, const std::string& target_url) = 0;
};
class NebulaBrowserClient final : public CefClient,
public CefDisplayHandler,
public CefKeyboardHandler,
public CefLifeSpanHandler,
public CefLoadHandler,
public CefPermissionHandler,
public CefRequestHandler {
public:
NebulaBrowserClient(BrowserRole role, BrowserClientDelegate* delegate);
CefRefPtr<CefDisplayHandler> GetDisplayHandler() override { return this; }
CefRefPtr<CefKeyboardHandler> GetKeyboardHandler() override { return this; }
CefRefPtr<CefLifeSpanHandler> GetLifeSpanHandler() override { return this; }
CefRefPtr<CefLoadHandler> GetLoadHandler() override { return this; }
CefRefPtr<CefPermissionHandler> GetPermissionHandler() override { return this; }
CefRefPtr<CefRequestHandler> GetRequestHandler() override { return this; }
bool OnProcessMessageReceived(CefRefPtr<CefBrowser> browser,
CefRefPtr<CefFrame> frame,
CefProcessId source_process,
CefRefPtr<CefProcessMessage> message) override;
void OnAddressChange(CefRefPtr<CefBrowser> browser,
CefRefPtr<CefFrame> frame,
const CefString& url) override;
void OnTitleChange(CefRefPtr<CefBrowser> browser,
const CefString& title) override;
void OnFaviconURLChange(CefRefPtr<CefBrowser> browser,
const std::vector<CefString>& icon_urls) override;
bool OnPreKeyEvent(CefRefPtr<CefBrowser> browser,
const CefKeyEvent& event,
CefEventHandle os_event,
bool* is_keyboard_shortcut) override;
bool OnBeforePopup(CefRefPtr<CefBrowser> browser,
CefRefPtr<CefFrame> frame,
int popup_id,
const CefString& target_url,
const CefString& target_frame_name,
CefLifeSpanHandler::WindowOpenDisposition target_disposition,
bool user_gesture,
const CefPopupFeatures& popupFeatures,
CefWindowInfo& windowInfo,
CefRefPtr<CefClient>& client,
CefBrowserSettings& settings,
CefRefPtr<CefDictionaryValue>& extra_info,
bool* no_javascript_access) override;
void OnAfterCreated(CefRefPtr<CefBrowser> browser) override;
void OnBeforeClose(CefRefPtr<CefBrowser> browser) override;
void OnLoadingStateChange(CefRefPtr<CefBrowser> browser,
bool isLoading,
bool canGoBack,
bool canGoForward) override;
void OnLoadStart(CefRefPtr<CefBrowser> browser,
CefRefPtr<CefFrame> frame,
TransitionType transition_type) override;
void OnLoadEnd(CefRefPtr<CefBrowser> browser,
CefRefPtr<CefFrame> frame,
int httpStatusCode) override;
bool OnBeforeBrowse(CefRefPtr<CefBrowser> browser,
CefRefPtr<CefFrame> frame,
CefRefPtr<CefRequest> request,
bool user_gesture,
bool is_redirect) override;
bool OnShowPermissionPrompt(CefRefPtr<CefBrowser> browser,
uint64_t prompt_id,
const CefString& requesting_origin,
uint32_t requested_permissions,
CefRefPtr<CefPermissionPromptCallback> callback) override;
private:
BrowserRole role_;
BrowserClientDelegate* delegate_ = nullptr;
IMPLEMENT_REFCOUNTING(NebulaBrowserClient);
};
} // namespace nebula::cef
+79
View File
@@ -0,0 +1,79 @@
#include "cef/nebula_app.h"
#include "include/cef_process_message.h"
#include "include/wrapper/cef_helpers.h"
namespace nebula::cef {
namespace {
constexpr char kChromeCommandMessage[] = "NebulaChromeCommand";
class NativeBridgeHandler final : public CefV8Handler {
public:
bool Execute(const CefString& name,
CefRefPtr<CefV8Value> object,
const CefV8ValueList& arguments,
CefRefPtr<CefV8Value>& retval,
CefString& exception) override {
UNREFERENCED_PARAMETER(object);
UNREFERENCED_PARAMETER(retval);
if (name != "postMessage") {
return false;
}
if (arguments.empty() || !arguments[0]->IsString()) {
exception = "nebulaNative.postMessage requires a command string.";
return true;
}
CefRefPtr<CefV8Context> context = CefV8Context::GetCurrentContext();
CefRefPtr<CefBrowser> browser = context ? context->GetBrowser() : nullptr;
CefRefPtr<CefFrame> frame = context ? context->GetFrame() : nullptr;
if (!browser || !frame) {
exception = "No CEF frame is available for native messaging.";
return true;
}
CefRefPtr<CefProcessMessage> message = CefProcessMessage::Create(kChromeCommandMessage);
CefRefPtr<CefListValue> args = message->GetArgumentList();
args->SetString(0, arguments[0]->GetStringValue());
args->SetString(1, arguments.size() > 1 && arguments[1]->IsString()
? arguments[1]->GetStringValue()
: CefString());
frame->SendProcessMessage(PID_BROWSER, message);
return true;
}
private:
IMPLEMENT_REFCOUNTING(NativeBridgeHandler);
};
} // namespace
void NebulaApp::OnBeforeCommandLineProcessing(const CefString& process_type,
CefRefPtr<CefCommandLine> command_line) {
UNREFERENCED_PARAMETER(process_type);
// The bundled UI is loaded from file:// and uses ES modules.
command_line->AppendSwitch("allow-file-access-from-files");
}
void NebulaApp::OnContextCreated(CefRefPtr<CefBrowser> browser,
CefRefPtr<CefFrame> frame,
CefRefPtr<CefV8Context> context) {
CEF_REQUIRE_RENDERER_THREAD();
UNREFERENCED_PARAMETER(browser);
UNREFERENCED_PARAMETER(frame);
CefRefPtr<CefV8Value> global = context->GetGlobal();
CefRefPtr<CefV8Value> native = CefV8Value::CreateObject(nullptr, nullptr);
native->SetValue(
"postMessage",
CefV8Value::CreateFunction("postMessage", new NativeBridgeHandler()),
V8_PROPERTY_ATTRIBUTE_NONE);
global->SetValue("nebulaNative", native, V8_PROPERTY_ATTRIBUTE_READONLY);
}
} // namespace nebula::cef
+25
View File
@@ -0,0 +1,25 @@
#pragma once
#include "include/cef_app.h"
#include "include/cef_render_process_handler.h"
#include "include/cef_v8.h"
namespace nebula::cef {
class NebulaApp final : public CefApp,
public CefRenderProcessHandler {
public:
CefRefPtr<CefRenderProcessHandler> GetRenderProcessHandler() override { return this; }
void OnBeforeCommandLineProcessing(const CefString& process_type,
CefRefPtr<CefCommandLine> command_line) override;
void OnContextCreated(CefRefPtr<CefBrowser> browser,
CefRefPtr<CefFrame> frame,
CefRefPtr<CefV8Context> context) override;
private:
IMPLEMENT_REFCOUNTING(NebulaApp);
};
} // namespace nebula::cef
+121
View File
@@ -0,0 +1,121 @@
#include "ui/paths.h"
#include <windows.h>
#include <algorithm>
#include <cctype>
namespace nebula::ui {
namespace {
std::string WideToUtf8(const std::wstring& value) {
if (value.empty()) {
return {};
}
const int size = WideCharToMultiByte(
CP_UTF8, 0, value.data(), static_cast<int>(value.size()),
nullptr, 0, nullptr, nullptr);
std::string result(size, '\0');
WideCharToMultiByte(
CP_UTF8, 0, value.data(), static_cast<int>(value.size()),
result.data(), size, nullptr, nullptr);
return result;
}
std::string GetUrlWithoutDecoration(std::string url) {
const size_t split = url.find_first_of("?#");
if (split != std::string::npos) {
url.resize(split);
}
return url;
}
std::string ToLowerAscii(std::string value) {
std::transform(value.begin(), value.end(), value.begin(), [](unsigned char ch) {
return static_cast<char>(std::tolower(ch));
});
return value;
}
} // namespace
std::filesystem::path GetExecutableDirectory() {
wchar_t exe_path[MAX_PATH] = {};
const DWORD length = GetModuleFileNameW(nullptr, exe_path, MAX_PATH);
if (length == 0 || length == MAX_PATH) {
return {};
}
return std::filesystem::path(exe_path).parent_path();
}
std::filesystem::path GetUiPagePath(const std::wstring& page_name) {
const auto exe_dir = GetExecutableDirectory();
if (exe_dir.empty()) {
return {};
}
return exe_dir / L"ui" / L"pages" / page_name;
}
std::string FilePathToUrl(std::filesystem::path path) {
std::string value = WideToUtf8(path.wstring());
for (char& ch : value) {
if (ch == '\\') {
ch = '/';
}
}
std::string encoded;
encoded.reserve(value.size());
for (char ch : value) {
encoded += ch == ' ' ? "%20" : std::string(1, ch);
}
return "file:///" + encoded;
}
std::string GetChromeUrl() {
const auto path = GetUiPagePath(L"chrome.html");
return path.empty() ? GetHomeUrl() : FilePathToUrl(path);
}
std::string GetHomeUrl() {
const auto path = GetUiPagePath(L"home.html");
return path.empty() ? "https://www.google.com" : FilePathToUrl(path);
}
std::string GetSettingsUrl() {
const auto path = GetUiPagePath(L"settings.html");
return path.empty() ? GetHomeUrl() : FilePathToUrl(path);
}
std::string GetBigPictureUrl() {
const auto path = GetUiPagePath(L"bigpicture.html");
return path.empty() ? GetHomeUrl() : FilePathToUrl(path);
}
std::string GetMenuPopupUrl() {
const auto path = GetUiPagePath(L"menu-popup.html");
return path.empty() ? GetHomeUrl() : FilePathToUrl(path);
}
bool IsInternalHomeUrl(const std::string& url) {
return GetUrlWithoutDecoration(url) == GetHomeUrl();
}
bool IsChromiumNewTabUrl(const std::string& url) {
const std::string target = ToLowerAscii(GetUrlWithoutDecoration(url));
return target == "about:blank" ||
target == "chrome://newtab" ||
target == "chrome://newtab/" ||
target == "chrome://new-tab-page" ||
target == "chrome://new-tab-page/" ||
target == "chrome-search://local-ntp/local-ntp.html";
}
bool IsEmptyOrChromiumNewTabUrl(const std::string& url) {
return url.empty() || IsChromiumNewTabUrl(url);
}
} // namespace nebula::ui
+21
View File
@@ -0,0 +1,21 @@
#pragma once
#include <filesystem>
#include <string>
namespace nebula::ui {
std::filesystem::path GetExecutableDirectory();
std::filesystem::path GetUiPagePath(const std::wstring& page_name);
std::string FilePathToUrl(std::filesystem::path path);
std::string GetChromeUrl();
std::string GetHomeUrl();
std::string GetSettingsUrl();
std::string GetBigPictureUrl();
std::string GetMenuPopupUrl();
bool IsInternalHomeUrl(const std::string& url);
bool IsChromiumNewTabUrl(const std::string& url);
bool IsEmptyOrChromiumNewTabUrl(const std::string& url);
} // namespace nebula::ui
+426
View File
@@ -0,0 +1,426 @@
#include "window/nebula_window.h"
#include <dwmapi.h>
#include <windowsx.h>
#include <algorithm>
namespace nebula::window {
namespace {
constexpr wchar_t kWindowClassName[] = L"NebulaBrowserWindow";
constexpr wchar_t kWindowTitle[] = L"Nebula Browser";
constexpr wchar_t kChildFrameHitTestOldProcProp[] = L"NebulaChildFrameHitTestOldProc";
constexpr wchar_t kChildFrameHitTestParentProp[] = L"NebulaChildFrameHitTestParent";
constexpr int kTitleRowHeightDip = 42;
constexpr int kWindowControlWidthDip = 46;
constexpr int kWindowControlCount = 3;
RECT GetWorkArea() {
RECT work_area = {};
SystemParametersInfoW(SPI_GETWORKAREA, 0, &work_area, 0);
return work_area;
}
bool IsResizeHit(LRESULT hit) {
return hit == HTLEFT || hit == HTRIGHT || hit == HTTOP || hit == HTBOTTOM ||
hit == HTTOPLEFT || hit == HTTOPRIGHT || hit == HTBOTTOMLEFT || hit == HTBOTTOMRIGHT;
}
HCURSOR CursorForResizeHit(LRESULT hit) {
switch (hit) {
case HTLEFT:
case HTRIGHT:
return LoadCursor(nullptr, IDC_SIZEWE);
case HTTOP:
case HTBOTTOM:
return LoadCursor(nullptr, IDC_SIZENS);
case HTTOPLEFT:
case HTBOTTOMRIGHT:
return LoadCursor(nullptr, IDC_SIZENWSE);
case HTTOPRIGHT:
case HTBOTTOMLEFT:
return LoadCursor(nullptr, IDC_SIZENESW);
default:
return nullptr;
}
}
bool SetResizeCursor(LRESULT hit) {
HCURSOR cursor = CursorForResizeHit(hit);
if (!cursor) {
return false;
}
SetCursor(cursor);
return true;
}
} // namespace
NebulaWindow::NebulaWindow(WindowDelegate* delegate) : delegate_(delegate) {}
NebulaWindow::~NebulaWindow() = default;
bool NebulaWindow::Create(HINSTANCE instance, int show_command) {
instance_ = instance;
RegisterClass(instance);
const RECT work_area = GetWorkArea();
dpi_ = GetDpiForSystem();
const int width = std::min<LONG>(ScaleForDpi(1400), work_area.right - work_area.left);
const int height = std::min<LONG>(ScaleForDpi(900), work_area.bottom - work_area.top);
const int x = work_area.left + ((work_area.right - work_area.left) - width) / 2;
const int y = work_area.top + ((work_area.bottom - work_area.top) - height) / 2;
hwnd_ = CreateWindowExW(
0,
kWindowClassName,
kWindowTitle,
WS_POPUP | WS_THICKFRAME | WS_MINIMIZEBOX | WS_MAXIMIZEBOX | WS_CLIPCHILDREN,
x,
y,
width,
height,
nullptr,
nullptr,
instance_,
this);
if (!hwnd_) {
return false;
}
UpdateDpi();
const MARGINS margins = {1, 1, 1, 1};
DwmExtendFrameIntoClientArea(hwnd_, &margins);
ShowWindow(hwnd_, show_command);
UpdateWindow(hwnd_);
return true;
}
BrowserLayout NebulaWindow::CurrentLayout() const {
RECT client = {};
if (hwnd_) {
GetClientRect(hwnd_, &client);
}
BrowserLayout layout;
layout.chrome = {0, 0, client.right, std::min<LONG>(ScaleForDpi(chrome_height_dip_), client.bottom)};
layout.content = {0, layout.chrome.bottom, client.right, client.bottom};
return layout;
}
void NebulaWindow::ResizeChild(HWND child, const RECT& rect) const {
if (!child) {
return;
}
EnableFrameHitTest(child);
SetWindowPos(
child,
nullptr,
rect.left,
rect.top,
std::max(0L, rect.right - rect.left),
std::max(0L, rect.bottom - rect.top),
SWP_NOZORDER | SWP_NOACTIVATE | SWP_NOOWNERZORDER);
}
void NebulaWindow::Minimize() {
if (hwnd_) {
ShowWindow(hwnd_, SW_MINIMIZE);
}
}
void NebulaWindow::ToggleMaximize() {
if (!hwnd_) {
return;
}
ShowWindow(hwnd_, IsZoomed(hwnd_) ? SW_RESTORE : SW_MAXIMIZE);
}
void NebulaWindow::Close() {
if (hwnd_) {
SendMessageW(hwnd_, WM_CLOSE, 0, 0);
}
}
void NebulaWindow::BeginDrag() {
if (!hwnd_) {
return;
}
ReleaseCapture();
SendMessageW(hwnd_, WM_NCLBUTTONDOWN, HTCAPTION, 0);
}
void NebulaWindow::SetTitle(const std::wstring& title) {
if (hwnd_) {
SetWindowTextW(hwnd_, title.empty() ? kWindowTitle : title.c_str());
}
}
void NebulaWindow::EnableFrameHitTest(HWND child) const {
if (!hwnd_ || !child) {
return;
}
EnableFrameHitTestForWindow(child);
EnumChildWindows(child, &NebulaWindow::EnableFrameHitTestForDescendant, reinterpret_cast<LPARAM>(this));
}
LRESULT CALLBACK NebulaWindow::StaticWndProc(HWND hwnd, UINT message, WPARAM wparam, LPARAM lparam) {
NebulaWindow* self = nullptr;
if (message == WM_NCCREATE) {
auto* create = reinterpret_cast<CREATESTRUCTW*>(lparam);
self = static_cast<NebulaWindow*>(create->lpCreateParams);
SetWindowLongPtrW(hwnd, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(self));
self->hwnd_ = hwnd;
} else {
self = reinterpret_cast<NebulaWindow*>(GetWindowLongPtrW(hwnd, GWLP_USERDATA));
}
return self ? self->WndProc(message, wparam, lparam)
: DefWindowProcW(hwnd, message, wparam, lparam);
}
LRESULT CALLBACK NebulaWindow::ChildFrameWndProc(HWND hwnd, UINT message, WPARAM wparam, LPARAM lparam) {
auto old_proc = reinterpret_cast<WNDPROC>(GetPropW(hwnd, kChildFrameHitTestOldProcProp));
if (message == WM_NCHITTEST) {
const auto parent = reinterpret_cast<HWND>(GetPropW(hwnd, kChildFrameHitTestParentProp));
auto* self = parent ? reinterpret_cast<NebulaWindow*>(GetWindowLongPtrW(parent, GWLP_USERDATA)) : nullptr;
if (self) {
const LRESULT hit = self->HitTest(lparam);
if (IsResizeHit(hit)) {
return hit;
}
}
}
if (message == WM_SETCURSOR) {
const auto parent = reinterpret_cast<HWND>(GetPropW(hwnd, kChildFrameHitTestParentProp));
auto* self = parent ? reinterpret_cast<NebulaWindow*>(GetWindowLongPtrW(parent, GWLP_USERDATA)) : nullptr;
POINT point = {};
if (self && GetCursorPos(&point) && SetResizeCursor(self->HitTestPoint(point))) {
return TRUE;
}
}
if (message == WM_MOUSEMOVE || message == WM_NCMOUSEMOVE) {
const auto parent = reinterpret_cast<HWND>(GetPropW(hwnd, kChildFrameHitTestParentProp));
auto* self = parent ? reinterpret_cast<NebulaWindow*>(GetWindowLongPtrW(parent, GWLP_USERDATA)) : nullptr;
POINT point = {};
if (self && GetCursorPos(&point) && SetResizeCursor(self->HitTestPoint(point))) {
return 0;
}
}
if (message == WM_NCLBUTTONDOWN && IsResizeHit(static_cast<LRESULT>(wparam))) {
const auto parent = reinterpret_cast<HWND>(GetPropW(hwnd, kChildFrameHitTestParentProp));
if (parent) {
ReleaseCapture();
SendMessageW(parent, WM_NCLBUTTONDOWN, wparam, lparam);
return 0;
}
}
if (message == WM_NCDESTROY) {
RemovePropW(hwnd, kChildFrameHitTestParentProp);
RemovePropW(hwnd, kChildFrameHitTestOldProcProp);
if (old_proc) {
SetWindowLongPtrW(hwnd, GWLP_WNDPROC, reinterpret_cast<LONG_PTR>(old_proc));
}
}
return old_proc ? CallWindowProcW(old_proc, hwnd, message, wparam, lparam)
: DefWindowProcW(hwnd, message, wparam, lparam);
}
BOOL CALLBACK NebulaWindow::EnableFrameHitTestForDescendant(HWND hwnd, LPARAM lparam) {
const auto* self = reinterpret_cast<const NebulaWindow*>(lparam);
if (self) {
self->EnableFrameHitTestForWindow(hwnd);
}
return TRUE;
}
LRESULT NebulaWindow::WndProc(UINT message, WPARAM wparam, LPARAM lparam) {
switch (message) {
case WM_CREATE:
UpdateDpi();
if (delegate_) {
delegate_->OnWindowCreated();
}
return 0;
case WM_NCCALCSIZE:
if (wparam == TRUE) {
return 0;
}
break;
case WM_ERASEBKGND:
return 1;
case WM_NCHITTEST:
return HitTest(lparam);
case WM_SETCURSOR: {
POINT point = {};
if (GetCursorPos(&point) && SetResizeCursor(HitTestPoint(point))) {
return TRUE;
}
break;
}
case WM_MOUSEMOVE:
case WM_NCMOUSEMOVE: {
POINT point = {};
if (GetCursorPos(&point) && SetResizeCursor(HitTestPoint(point))) {
return 0;
}
break;
}
case WM_SIZE:
NotifyResize();
return 0;
case WM_DPICHANGED: {
dpi_ = HIWORD(wparam);
const auto* suggested_rect = reinterpret_cast<RECT*>(lparam);
SetWindowPos(
hwnd_,
nullptr,
suggested_rect->left,
suggested_rect->top,
suggested_rect->right - suggested_rect->left,
suggested_rect->bottom - suggested_rect->top,
SWP_NOZORDER | SWP_NOACTIVATE);
NotifyResize();
return 0;
}
case WM_CLOSE:
if (delegate_) {
delegate_->OnWindowCloseRequested();
return 0;
}
break;
case WM_DESTROY:
hwnd_ = nullptr;
return 0;
}
return DefWindowProcW(hwnd_, message, wparam, lparam);
}
void NebulaWindow::RegisterClass(HINSTANCE instance) {
WNDCLASSEXW window_class = {};
window_class.cbSize = sizeof(window_class);
window_class.lpfnWndProc = StaticWndProc;
window_class.hInstance = instance;
window_class.hCursor = LoadCursor(nullptr, IDC_ARROW);
window_class.lpszClassName = kWindowClassName;
RegisterClassExW(&window_class);
}
void NebulaWindow::NotifyResize() {
if (delegate_) {
delegate_->OnWindowResized(CurrentLayout());
}
}
void NebulaWindow::EnableFrameHitTestForWindow(HWND child) const {
if (!child || GetPropW(child, kChildFrameHitTestOldProcProp)) {
return;
}
SetPropW(child, kChildFrameHitTestParentProp, hwnd_);
const auto old_proc = reinterpret_cast<WNDPROC>(
SetWindowLongPtrW(child, GWLP_WNDPROC, reinterpret_cast<LONG_PTR>(&NebulaWindow::ChildFrameWndProc)));
if (old_proc) {
SetPropW(child, kChildFrameHitTestOldProcProp, reinterpret_cast<HANDLE>(old_proc));
} else {
RemovePropW(child, kChildFrameHitTestParentProp);
}
}
int NebulaWindow::ScaleForDpi(int value) const {
return MulDiv(value, static_cast<int>(dpi_), 96);
}
void NebulaWindow::UpdateDpi() {
if (hwnd_) {
dpi_ = GetDpiForWindow(hwnd_);
}
}
LRESULT NebulaWindow::HitTest(LPARAM lparam) const {
if (!hwnd_) {
return HTNOWHERE;
}
POINT point = {GET_X_LPARAM(lparam), GET_Y_LPARAM(lparam)};
return HitTestPoint(point);
}
LRESULT NebulaWindow::HitTestPoint(POINT point) const {
if (!hwnd_) {
return HTNOWHERE;
}
RECT window = {};
GetWindowRect(hwnd_, &window);
const int resize_border = ScaleForDpi(resize_border_dip_);
const bool left = point.x >= window.left && point.x < window.left + resize_border;
const bool right = point.x < window.right && point.x >= window.right - resize_border;
const bool top = point.y >= window.top && point.y < window.top + resize_border;
const bool bottom = point.y < window.bottom && point.y >= window.bottom - resize_border;
if (top && left) {
return HTTOPLEFT;
}
if (top && right) {
return HTTOPRIGHT;
}
if (bottom && left) {
return HTBOTTOMLEFT;
}
if (bottom && right) {
return HTBOTTOMRIGHT;
}
if (left) {
return HTLEFT;
}
if (right) {
return HTRIGHT;
}
if (top) {
return HTTOP;
}
if (bottom) {
return HTBOTTOM;
}
const int controls_width = ScaleForDpi(kWindowControlWidthDip * kWindowControlCount);
const int controls_height = ScaleForDpi(kTitleRowHeightDip);
const bool window_controls = point.x >= window.right - controls_width && point.x < window.right &&
point.y >= window.top && point.y < window.top + controls_height;
if (window_controls) {
return HTCLIENT;
}
return HTCLIENT;
}
} // namespace nebula::window
+61
View File
@@ -0,0 +1,61 @@
#pragma once
#include <windows.h>
#include <string>
namespace nebula::window {
struct BrowserLayout {
RECT chrome = {};
RECT content = {};
};
class WindowDelegate {
public:
virtual ~WindowDelegate() = default;
virtual void OnWindowCreated() = 0;
virtual void OnWindowResized(const BrowserLayout& layout) = 0;
virtual void OnWindowCloseRequested() = 0;
};
class NebulaWindow {
public:
explicit NebulaWindow(WindowDelegate* delegate);
~NebulaWindow();
bool Create(HINSTANCE instance, int show_command);
HWND hwnd() const { return hwnd_; }
BrowserLayout CurrentLayout() const;
void ResizeChild(HWND child, const RECT& rect) const;
void Minimize();
void ToggleMaximize();
void Close();
void BeginDrag();
void SetTitle(const std::wstring& title);
void EnableFrameHitTest(HWND child) const;
private:
static LRESULT CALLBACK StaticWndProc(HWND hwnd, UINT message, WPARAM wparam, LPARAM lparam);
static LRESULT CALLBACK ChildFrameWndProc(HWND hwnd, UINT message, WPARAM wparam, LPARAM lparam);
static BOOL CALLBACK EnableFrameHitTestForDescendant(HWND hwnd, LPARAM lparam);
LRESULT WndProc(UINT message, WPARAM wparam, LPARAM lparam);
void RegisterClass(HINSTANCE instance);
void NotifyResize();
void EnableFrameHitTestForWindow(HWND child) const;
LRESULT HitTest(LPARAM lparam) const;
LRESULT HitTestPoint(POINT point) const;
int ScaleForDpi(int value) const;
void UpdateDpi();
WindowDelegate* delegate_ = nullptr;
HINSTANCE instance_ = nullptr;
HWND hwnd_ = nullptr;
UINT dpi_ = 96;
int resize_border_dip_ = 8;
int chrome_height_dip_ = 104;
};
} // namespace nebula::window
+383
View File
@@ -0,0 +1,383 @@
:root {
--bg: #080a0f;
--surface: #0e1119;
--surface-raised: #141824;
--surface-hover: rgba(255, 255, 255, 0.06);
--text: #e8e8f0;
--muted: #7a7e90;
--accent: #7b2eff;
--accent-2: #00c6ff;
--outline: #1f2533;
--outline-soft: rgba(255, 255, 255, 0.06);
--danger: #e0445c;
color-scheme: dark;
}
@font-face {
font-family: "InterVariable";
src: url("../assets/fonts/InterVariable.ttf") format("truetype");
font-weight: 100 900;
font-display: swap;
}
* {
box-sizing: border-box;
}
html,
body {
width: 100%;
height: 100%;
margin: 0;
overflow: hidden;
}
body {
background: var(--bg);
color: var(--text);
font-family: "InterVariable", "Segoe UI", system-ui, sans-serif;
user-select: none;
}
button,
input {
font: inherit;
}
button {
border: 0;
color: var(--text);
cursor: pointer;
}
button:disabled {
cursor: default;
opacity: 0.3;
}
/* ── Chrome shell ───────────────────────────────────────────── */
.nebula-chrome {
display: grid;
grid-template-rows: 42px 52px;
height: 100%;
border-bottom: 1px solid var(--outline);
}
/* ── Title row ──────────────────────────────────────────────── */
.title-row,
.toolbar {
display: flex;
align-items: center;
}
.title-row {
gap: 10px;
padding: 0 0 0 12px;
background: var(--bg);
}
/* ── Brand ──────────────────────────────────────────────────── */
.brand {
display: flex;
align-items: center;
gap: 7px;
min-width: 104px;
color: var(--accent-2);
font-size: 0.73rem;
font-weight: 800;
letter-spacing: 0.15em;
text-transform: uppercase;
opacity: 0.85;
}
.brand-icon {
width: 17px;
height: 17px;
}
/* ── Tabs ───────────────────────────────────────────────────── */
.tabs {
display: flex;
align-items: flex-end;
gap: 3px;
min-width: 0;
flex: 1;
height: 100%;
}
.tab {
display: flex;
align-items: center;
gap: 7px;
min-width: 0;
width: min(260px, 38vw);
height: 33px;
padding: 0 14px;
border-radius: 10px 10px 0 0;
border: 1px solid transparent;
border-bottom: none;
background: transparent;
color: var(--muted);
cursor: pointer;
font-size: 0.82rem;
transition: background 120ms, color 120ms;
}
.tab:hover:not(.active) {
background: var(--surface);
color: var(--text);
}
.tab.active {
background: var(--surface-raised);
border-color: var(--outline);
color: var(--text);
}
.tab-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}
.tab-favicon,
.tab-loading {
width: 16px;
height: 16px;
flex: 0 0 auto;
border-radius: 3px;
}
.tab-favicon {
display: flex;
align-items: center;
justify-content: center;
background: var(--accent);
opacity: 0.85;
border-radius: 999px;
overflow: hidden;
}
.tab-favicon.has-favicon {
background: transparent;
border-radius: 3px;
opacity: 1;
}
.tab-favicon.empty {
width: 13px;
height: 13px;
}
.tab-favicon img {
display: block;
width: 16px;
height: 16px;
object-fit: contain;
}
.tab-loading {
width: 13px;
height: 13px;
border: 2px solid rgba(0, 198, 255, 0.2);
border-top-color: var(--accent-2);
border-radius: 999px;
animation: spin 0.8s linear infinite;
}
.tab-close {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
padding: 0;
border-radius: 6px;
background: transparent;
color: var(--muted);
opacity: 0;
transition: background 120ms, color 120ms, opacity 120ms;
}
.tab:hover .tab-close,
.tab.active .tab-close,
.tab-close:focus-visible {
opacity: 1;
}
.tab-close:hover {
background: var(--surface-hover);
color: var(--text);
}
.tab-add {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
margin-bottom: 2px;
border-radius: 8px;
border: 1px solid transparent;
background: transparent;
color: var(--muted);
transition: background 120ms, color 120ms, border-color 120ms;
}
.tab-add:hover {
background: var(--surface-hover);
border-color: var(--outline);
color: var(--text);
}
/* ── Window controls ────────────────────────────────────────── */
.window-controls {
display: flex;
align-self: stretch;
margin: 0;
overflow: hidden;
border-top-right-radius: 10px;
}
.window-controls button {
display: flex;
align-items: center;
justify-content: center;
width: 46px;
background: transparent;
color: var(--muted);
transition: background 100ms, color 100ms;
}
.window-controls button:hover {
background: var(--surface-hover);
color: var(--text);
}
.window-controls .close:hover {
background: var(--danger);
color: white;
}
/* ── Toolbar ────────────────────────────────────────────────── */
.toolbar {
gap: 4px;
padding: 0 12px;
background: var(--surface-raised);
border-top: 1px solid var(--outline);
}
/* ── Lucide icon sizing ─────────────────────────────────────── */
/* Lucide replaces <i data-lucide> with <svg>; enforce consistent size */
.icon-button svg,
.tab-close svg,
.tab-add svg,
.window-controls svg {
width: 16px;
height: 16px;
display: block;
stroke-width: 1.75;
pointer-events: none;
}
/* ── Icon buttons ───────────────────────────────────────────── */
.icon-button {
display: flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
padding: 0;
border-radius: 9px;
background: transparent;
border: 1px solid transparent;
color: var(--muted);
flex-shrink: 0;
transition: background 120ms, color 120ms, border-color 120ms;
}
.icon-button:hover:not(:disabled) {
background: var(--surface-hover);
border-color: var(--outline);
color: var(--text);
}
/* ── Address bar ────────────────────────────────────────────── */
.address-shell {
position: relative;
display: flex;
align-items: center;
min-width: 160px;
height: 36px;
flex: 1;
margin: 0 4px;
overflow: hidden;
border: 1px solid var(--outline);
border-radius: 10px;
background: var(--surface);
transition: border-color 140ms, box-shadow 140ms;
}
.address-shell:focus-within {
border-color: rgba(123, 46, 255, 0.55);
box-shadow: 0 0 0 3px rgba(123, 46, 255, 0.12);
}
.address-shell input {
width: 100%;
height: 100%;
border: 0;
outline: 0;
padding: 0 16px;
background: transparent;
color: var(--text);
font-size: 0.84rem;
}
.address-shell input::placeholder {
color: var(--muted);
}
.progress-bar {
position: absolute;
bottom: 0;
left: 0;
height: 2px;
width: 0%;
pointer-events: none;
background: var(--accent);
border-radius: 0 2px 2px 0;
opacity: 0.7;
transition: width 120ms ease, opacity 160ms ease;
}
/* ── Utilities ──────────────────────────────────────────────── */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
+182
View File
@@ -0,0 +1,182 @@
const SEARCH_URL = 'https://www.google.com/search?q=';
const state = {
id: 1,
url: '',
title: 'New Tab',
isLoading: false,
progress: 0,
canGoBack: false,
canGoForward: false,
favicon: '',
tabs: []
};
function toNavigationUrl(input) {
const value = (input || '').trim();
if (!value) return null;
if (/^(https?:|file:|data:|blob:|chrome:)/i.test(value)) return value;
if (value.includes('.') && !/\s/.test(value)) return `https://${value}`;
return `${SEARCH_URL}${encodeURIComponent(value)}`;
}
function postCommand(command, payload = '') {
if (window.nebulaNative && typeof window.nebulaNative.postMessage === 'function') {
window.nebulaNative.postMessage(command, String(payload));
}
}
function renderFavicon(favicon, tab) {
const url = (tab.favicon || '').trim();
favicon.className = 'tab-favicon';
favicon.textContent = '';
if (!url) {
favicon.classList.add('empty');
return;
}
const image = document.createElement('img');
image.alt = '';
image.decoding = 'async';
image.draggable = false;
image.addEventListener('load', () => {
favicon.classList.add('has-favicon');
});
image.addEventListener('error', () => {
image.remove();
favicon.classList.remove('has-favicon');
favicon.classList.add('empty');
});
favicon.append(image);
image.src = url;
}
function renderTabs() {
const tabsElement = document.querySelector('.tabs');
const addButton = tabsElement.querySelector('.tab-add');
const tabs = state.tabs.length
? state.tabs
: [{ id: state.id, title: state.title, isLoading: state.isLoading, favicon: state.favicon }];
tabsElement.querySelectorAll('.tab').forEach(tab => tab.remove());
tabs.forEach(tab => {
const button = document.createElement('div');
const isActive = tab.id === state.id;
button.className = `tab${isActive ? ' active' : ''}`;
button.setAttribute('role', 'tab');
button.setAttribute('aria-selected', String(isActive));
button.tabIndex = 0;
button.dataset.tabId = String(tab.id);
const favicon = document.createElement('span');
renderFavicon(favicon, tab);
const title = document.createElement('span');
title.className = 'tab-title';
title.textContent = tab.title || 'New Tab';
const loading = document.createElement('span');
loading.className = 'tab-loading';
loading.hidden = !tab.isLoading;
const close = document.createElement('button');
close.className = 'tab-close';
close.type = 'button';
close.title = 'Close tab';
close.setAttribute('aria-label', `Close ${tab.title || 'New Tab'}`);
close.dataset.tabId = String(tab.id);
close.innerHTML = '<i data-lucide="x"></i>';
button.append(favicon, title, loading, close);
tabsElement.insertBefore(button, addButton);
});
if (window.lucide) lucide.createIcons({ nodes: [tabsElement] });
}
function applyState(nextState) {
Object.assign(state, nextState || {});
const title = state.title || 'New Tab';
const url = state.url || '';
const addressInput = document.getElementById('address-input');
const backButton = document.getElementById('back-button');
const forwardButton = document.getElementById('forward-button');
const reloadButton = document.getElementById('reload-button');
const progressBar = document.getElementById('progress-bar');
document.title = `${title} - Nebula`;
renderTabs();
backButton.disabled = !state.canGoBack;
forwardButton.disabled = !state.canGoForward;
const reloadIcon = state.isLoading ? 'x' : 'rotate-cw';
reloadButton.dataset.command = state.isLoading ? 'stop' : 'reload';
reloadButton.innerHTML = `<i data-lucide="${reloadIcon}"></i>`;
if (window.lucide) lucide.createIcons({ nodes: [reloadButton] });
if (document.activeElement !== addressInput) {
addressInput.value = url;
}
progressBar.style.width = `${Math.max(0, Math.min(1, state.progress || 0)) * 100}%`;
progressBar.style.opacity = state.isLoading ? '1' : '0';
}
function wireCommands() {
document.querySelector('.tabs').addEventListener('click', event => {
const close = event.target.closest('.tab-close[data-tab-id]');
if (close) {
postCommand('close-tab', close.dataset.tabId);
return;
}
const tab = event.target.closest('.tab[data-tab-id]');
if (tab && !tab.classList.contains('active')) {
postCommand('activate-tab', tab.dataset.tabId);
}
});
document.querySelector('.tabs').addEventListener('auxclick', event => {
if (event.button !== 1) return;
const tab = event.target.closest('.tab[data-tab-id]');
if (tab) {
postCommand('close-tab', tab.dataset.tabId);
}
});
document.querySelectorAll('[data-command]').forEach(button => {
button.addEventListener('click', () => {
postCommand(button.dataset.command);
});
});
document.querySelectorAll('[data-drag-region]').forEach(region => {
region.addEventListener('pointerdown', event => {
const interactive = event.target.closest('button, input, .tab, .address-shell');
if (event.button === 0 && !interactive) {
postCommand('drag');
}
});
});
document.getElementById('address-form').addEventListener('submit', event => {
event.preventDefault();
const input = document.getElementById('address-input');
const target = toNavigationUrl(input.value);
if (target) {
postCommand('navigate', target);
input.blur();
}
});
}
window.NebulaChrome = { applyState, postCommand, toNavigationUrl };
document.addEventListener('DOMContentLoaded', () => {
wireCommands();
applyState(state);
});
+74
View File
@@ -0,0 +1,74 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Nebula Chrome</title>
<link rel="stylesheet" href="../css/chrome.css">
</head>
<body>
<div class="nebula-chrome" data-drag-region>
<div class="title-row" data-drag-region>
<div class="brand" data-drag-region>
<img src="../assets/images/branding/Nebula-Icon.svg" alt="" class="brand-icon">
<span>Nebula</span>
</div>
<div class="tabs" role="tablist" aria-label="Nebula tabs">
<button id="active-tab" class="tab active" type="button" role="tab" aria-selected="true">
<span id="tab-favicon" class="tab-favicon"></span>
<span id="tab-title" class="tab-title">New Tab</span>
<span id="tab-loading" class="tab-loading" hidden></span>
</button>
<button class="tab-add" type="button" data-command="new-tab" title="New tab" aria-label="New tab">
<i data-lucide="plus"></i>
</button>
</div>
<div class="window-controls" aria-label="Window controls">
<button type="button" data-command="minimize" aria-label="Minimize">
<i data-lucide="minus"></i>
</button>
<button type="button" data-command="maximize" aria-label="Maximize">
<i data-lucide="square"></i>
</button>
<button type="button" data-command="close" class="close" aria-label="Close">
<i data-lucide="x"></i>
</button>
</div>
</div>
<div class="toolbar">
<button id="back-button" class="icon-button" type="button" data-command="back" aria-label="Back" disabled>
<i data-lucide="chevron-left"></i>
</button>
<button id="forward-button" class="icon-button" type="button" data-command="forward" aria-label="Forward" disabled>
<i data-lucide="chevron-right"></i>
</button>
<button id="reload-button" class="icon-button" type="button" data-command="reload" aria-label="Reload">
<i data-lucide="rotate-cw"></i>
</button>
<button class="icon-button" type="button" data-command="home" aria-label="Home">
<i data-lucide="home"></i>
</button>
<form id="address-form" class="address-shell" autocomplete="off">
<div id="progress-bar" class="progress-bar"></div>
<label class="sr-only" for="address-input">Search or enter address</label>
<input id="address-input" type="text" spellcheck="false" placeholder="Search or enter address">
</form>
<button class="icon-button" type="button" data-command="settings" aria-label="Settings">
<i data-lucide="settings"></i>
</button>
<button class="icon-button menu-button" type="button" data-command="menu-popup" aria-label="Open menu" title="Open menu">
<i data-lucide="menu"></i>
</button>
</div>
</div>
<script src="../js/lucide.min.js"></script>
<script>lucide.createIcons();</script>
<script src="../js/chrome.js"></script>
</body>
</html>