diff --git a/CMakeLists.txt b/CMakeLists.txt index 22e82b8..92f429f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 diff --git a/app/main.cpp b/app/main.cpp index c888689..27ae6be 100644 --- a/app/main.cpp +++ b/app/main.cpp @@ -1,333 +1,6 @@ #include -#include -#include -#include -#include - -#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(value.size()), - nullptr, 0, nullptr, nullptr); - std::string result(size, '\0'); - WideCharToMultiByte( - CP_UTF8, 0, value.data(), static_cast(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(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 GetDisplayHandler() override { - return this; - } - - CefRefPtr GetKeyboardHandler() override { - return this; - } - - CefRefPtr GetLifeSpanHandler() override { - return this; - } - - CefRefPtr GetPermissionHandler() override { - return this; - } - - CefRefPtr GetRequestHandler() override { - return this; - } - - void OnAddressChange(CefRefPtr browser, - CefRefPtr frame, - const CefString& url) override { - CEF_REQUIRE_UI_THREAD(); - - if (browser && frame && frame->IsMain() && - IsChromiumNewTabUrl(url)) { - browser->GetMainFrame()->LoadURL(GetHomeUrl()); - } - } - - void OnTitleChange(CefRefPtr 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 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 browser, - CefRefPtr 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& client, - CefBrowserSettings& settings, - CefRefPtr& 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 browser, - CefRefPtr frame, - CefRefPtr 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 browser) override { - CEF_REQUIRE_UI_THREAD(); - ++browser_count_; - - if (browser_count_ > 1 && browser && - IsEmptyOrChromiumNewTabUrl(browser->GetMainFrame()->GetURL())) { - browser->GetMainFrame()->LoadURL(GetHomeUrl()); - } - } - - void OnBeforeClose(CefRefPtr browser) override { - CEF_REQUIRE_UI_THREAD(); - - --browser_count_; - if (browser_count_ == 0) { - CefQuitMessageLoop(); - } - } - - bool OnShowPermissionPrompt( - CefRefPtr browser, - uint64_t prompt_id, - const CefString& requesting_origin, - uint32_t requested_permissions, - CefRefPtr 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 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 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 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 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); } diff --git a/src/app/nebula_controller.cpp b/src/app/nebula_controller.cpp new file mode 100644 index 0000000..2680100 --- /dev/null +++ b/src/app/nebula_controller.cpp @@ -0,0 +1,526 @@ +#include "app/nebula_controller.h" + +#include + +#include +#include +#include + +#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(value.size()), nullptr, 0); + std::wstring result(size, L'\0'); + MultiByteToWideChar( + CP_UTF8, 0, value.data(), static_cast(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(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(margin, client.right - width - margin); + const LONG y = std::max(0, layout.chrome.bottom - overlap); + return { + x, + y, + std::min(client.right, x + width), + std::min(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(1, rect.right - rect.left) + 1, + std::max(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 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(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 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 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 browser, const std::string& url) { + tabs_.UpdateURL(browser, nebula::ui::IsChromiumNewTabUrl(url) ? nebula::ui::GetHomeUrl() : url); +} + +void NebulaController::OnContentTitleChanged(CefRefPtr 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 browser, bool is_loading) { + tabs_.UpdateLoadingState(browser, is_loading); +} + +void NebulaController::OnContentLoadProgressChanged(CefRefPtr browser, double progress) { + tabs_.UpdateLoadProgress(browser, progress); +} + +void NebulaController::OnContentFaviconChanged(CefRefPtr browser, const std::vector& urls) { + tabs_.UpdateFavicon(browser, urls); +} + +void NebulaController::OnPopupRequested(CefRefPtr 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 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 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 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 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 diff --git a/src/app/nebula_controller.h b/src/app/nebula_controller.h new file mode 100644 index 0000000..9a4a899 --- /dev/null +++ b/src/app/nebula_controller.h @@ -0,0 +1,70 @@ +#pragma once + +#include +#include +#include + +#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 browser) override; + void OnBrowserClosing(nebula::cef::BrowserRole role, CefRefPtr browser) override; + void OnChromeCommand(const std::string& command, const std::string& payload) override; + void OnContentAddressChanged(CefRefPtr browser, const std::string& url) override; + void OnContentTitleChanged(CefRefPtr browser, const std::string& title) override; + void OnContentLoadingStateChanged(CefRefPtr browser, bool is_loading) override; + void OnContentLoadProgressChanged(CefRefPtr browser, double progress) override; + void OnContentFaviconChanged(CefRefPtr browser, const std::vector& urls) override; + void OnPopupRequested(CefRefPtr 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 window_; + nebula::browser::TabManager tabs_; + CefRefPtr chrome_browser_; + CefRefPtr menu_popup_browser_; + CefRefPtr chrome_client_; + CefRefPtr content_client_; + CefRefPtr menu_popup_client_; +}; + +} // namespace nebula::app diff --git a/src/app/run.cpp b/src/app/run.cpp new file mode 100644 index 0000000..07af574 --- /dev/null +++ b/src/app/run.cpp @@ -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 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 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 diff --git a/src/app/run.h b/src/app/run.h new file mode 100644 index 0000000..16fa2e9 --- /dev/null +++ b/src/app/run.h @@ -0,0 +1,9 @@ +#pragma once + +#include + +namespace nebula::app { + +int RunNebula(HINSTANCE instance, int show_command); + +} // namespace nebula::app diff --git a/src/browser/tab.cpp b/src/browser/tab.cpp new file mode 100644 index 0000000..bdf2ec2 --- /dev/null +++ b/src/browser/tab.cpp @@ -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 diff --git a/src/browser/tab.h b/src/browser/tab.h new file mode 100644 index 0000000..6fa7c63 --- /dev/null +++ b/src/browser/tab.h @@ -0,0 +1,23 @@ +#pragma once + +#include +#include + +#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 browser; + + bool CanGoBack() const; + bool CanGoForward() const; +}; + +} // namespace nebula::browser diff --git a/src/browser/tab_manager.cpp b/src/browser/tab_manager.cpp new file mode 100644 index 0000000..f052a43 --- /dev/null +++ b/src/browser/tab_manager.cpp @@ -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& 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 TabManager::CloseTab(int tab_id) { + for (auto it = tabs_.begin(); it != tabs_.end(); ++it) { + if (it->id != tab_id) { + continue; + } + + CefRefPtr 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 browser) { + if (NebulaTab* tab = ActiveTab()) { + tab->browser = browser; + if (browser && tab->url.empty()) { + tab->url = browser->GetMainFrame()->GetURL(); + } + Notify(); + } +} + +bool TabManager::OwnsBrowser(CefRefPtr 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 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 browser, std::string url) { + if (NebulaTab* tab = FindTab(browser)) { + tab->url = std::move(url); + Notify(); + } +} + +void TabManager::UpdateTitle(CefRefPtr browser, std::string title) { + if (NebulaTab* tab = FindTab(browser)) { + tab->title = title.empty() ? "New Tab" : std::move(title); + Notify(); + } +} + +void TabManager::UpdateLoadingState(CefRefPtr 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 browser, double progress) { + if (NebulaTab* tab = FindTab(browser)) { + tab->load_progress = progress; + Notify(); + } +} + +void TabManager::UpdateFavicon(CefRefPtr browser, const std::vector& 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 browser) { + if (!browser) { + return nullptr; + } + + for (auto& tab : tabs_) { + if (tab.browser && tab.browser->IsSame(browser)) { + return &tab; + } + } + return nullptr; +} + +} // namespace nebula::browser diff --git a/src/browser/tab_manager.h b/src/browser/tab_manager.h new file mode 100644 index 0000000..1f0c8a6 --- /dev/null +++ b/src/browser/tab_manager.h @@ -0,0 +1,56 @@ +#pragma once + +#include +#include + +#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& Tabs() const; + + bool ActivateTab(int tab_id); + CefRefPtr CloseTab(int tab_id); + void SetActiveBrowser(CefRefPtr browser); + bool OwnsBrowser(CefRefPtr browser) const; + void ClearBrowser(CefRefPtr browser); + bool HasOpenBrowsers() const; + + void LoadURL(const std::string& input); + void GoBack(); + void GoForward(); + void Reload(); + void StopLoad(); + + void UpdateURL(CefRefPtr browser, std::string url); + void UpdateTitle(CefRefPtr browser, std::string title); + void UpdateLoadingState(CefRefPtr browser, bool is_loading); + void UpdateLoadProgress(CefRefPtr browser, double progress); + void UpdateFavicon(CefRefPtr browser, const std::vector& urls); + +private: + void Notify(); + NebulaTab* FindTab(int tab_id); + NebulaTab* FindTab(CefRefPtr browser); + + TabObserver* observer_ = nullptr; + std::vector tabs_; + int active_tab_id_ = 0; + int next_tab_id_ = 1; +}; + +} // namespace nebula::browser diff --git a/src/browser/url_utils.cpp b/src/browser/url_utils.cpp new file mode 100644 index 0000000..3338587 --- /dev/null +++ b/src/browser/url_utils.cpp @@ -0,0 +1,110 @@ +#include "browser/url_utils.h" + +#include +#include +#include + +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(value.front()))) { + value.erase(value.begin()); + } + while (!value.empty() && std::isspace(static_cast(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(ch); + } else if (ch == ' ') { + encoded << '+'; + } else { + encoded << '%' << std::setw(2) << std::setfill('0') << static_cast(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(ch); + } else { + escaped << static_cast(ch); + } + break; + } + } + return escaped.str(); +} + +} // namespace nebula::browser diff --git a/src/browser/url_utils.h b/src/browser/url_utils.h new file mode 100644 index 0000000..75b2cb1 --- /dev/null +++ b/src/browser/url_utils.h @@ -0,0 +1,10 @@ +#pragma once + +#include + +namespace nebula::browser { + +std::string NormalizeNavigationInput(const std::string& input); +std::string JsonEscape(const std::string& value); + +} // namespace nebula::browser diff --git a/src/cef/browser_client.cpp b/src/cef/browser_client.cpp new file mode 100644 index 0000000..6effe3b --- /dev/null +++ b/src/cef/browser_client.cpp @@ -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 ToStringVector(const std::vector& values) { + std::vector 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 browser, + CefRefPtr frame, + CefProcessId source_process, + CefRefPtr 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 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 browser, + CefRefPtr 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 browser, + const CefString& title) { + CEF_REQUIRE_UI_THREAD(); + if (role_ == BrowserRole::Content && delegate_) { + delegate_->OnContentTitleChanged(browser, title.ToString()); + } +} + +void NebulaBrowserClient::OnFaviconURLChange(CefRefPtr browser, + const std::vector& icon_urls) { + CEF_REQUIRE_UI_THREAD(); + if (role_ == BrowserRole::Content && delegate_) { + delegate_->OnContentFaviconChanged(browser, ToStringVector(icon_urls)); + } +} + +bool NebulaBrowserClient::OnPreKeyEvent(CefRefPtr 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 browser, + CefRefPtr 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& client, + CefBrowserSettings& settings, + CefRefPtr& 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 browser) { + CEF_REQUIRE_UI_THREAD(); + if (delegate_) { + delegate_->OnBrowserCreated(role_, browser); + } +} + +void NebulaBrowserClient::OnBeforeClose(CefRefPtr browser) { + CEF_REQUIRE_UI_THREAD(); + if (delegate_) { + delegate_->OnBrowserClosing(role_, browser); + } +} + +void NebulaBrowserClient::OnLoadingStateChange(CefRefPtr 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 browser, + CefRefPtr 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 browser, + CefRefPtr 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 browser, + CefRefPtr frame, + CefRefPtr 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 browser, + uint64_t prompt_id, + const CefString& requesting_origin, + uint32_t requested_permissions, + CefRefPtr 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 diff --git a/src/cef/browser_client.h b/src/cef/browser_client.h new file mode 100644 index 0000000..f53da04 --- /dev/null +++ b/src/cef/browser_client.h @@ -0,0 +1,118 @@ +#pragma once + +#include +#include + +#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 browser) = 0; + virtual void OnBrowserClosing(BrowserRole role, CefRefPtr browser) = 0; + virtual void OnChromeCommand(const std::string& command, const std::string& payload) = 0; + virtual void OnContentAddressChanged(CefRefPtr browser, const std::string& url) = 0; + virtual void OnContentTitleChanged(CefRefPtr browser, const std::string& title) = 0; + virtual void OnContentLoadingStateChanged(CefRefPtr browser, bool is_loading) = 0; + virtual void OnContentLoadProgressChanged(CefRefPtr browser, double progress) = 0; + virtual void OnContentFaviconChanged(CefRefPtr browser, const std::vector& urls) = 0; + virtual void OnPopupRequested(CefRefPtr 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 GetDisplayHandler() override { return this; } + CefRefPtr GetKeyboardHandler() override { return this; } + CefRefPtr GetLifeSpanHandler() override { return this; } + CefRefPtr GetLoadHandler() override { return this; } + CefRefPtr GetPermissionHandler() override { return this; } + CefRefPtr GetRequestHandler() override { return this; } + + bool OnProcessMessageReceived(CefRefPtr browser, + CefRefPtr frame, + CefProcessId source_process, + CefRefPtr message) override; + + void OnAddressChange(CefRefPtr browser, + CefRefPtr frame, + const CefString& url) override; + void OnTitleChange(CefRefPtr browser, + const CefString& title) override; + void OnFaviconURLChange(CefRefPtr browser, + const std::vector& icon_urls) override; + + bool OnPreKeyEvent(CefRefPtr browser, + const CefKeyEvent& event, + CefEventHandle os_event, + bool* is_keyboard_shortcut) override; + + bool OnBeforePopup(CefRefPtr browser, + CefRefPtr 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& client, + CefBrowserSettings& settings, + CefRefPtr& extra_info, + bool* no_javascript_access) override; + + void OnAfterCreated(CefRefPtr browser) override; + void OnBeforeClose(CefRefPtr browser) override; + + void OnLoadingStateChange(CefRefPtr browser, + bool isLoading, + bool canGoBack, + bool canGoForward) override; + void OnLoadStart(CefRefPtr browser, + CefRefPtr frame, + TransitionType transition_type) override; + void OnLoadEnd(CefRefPtr browser, + CefRefPtr frame, + int httpStatusCode) override; + + bool OnBeforeBrowse(CefRefPtr browser, + CefRefPtr frame, + CefRefPtr request, + bool user_gesture, + bool is_redirect) override; + + bool OnShowPermissionPrompt(CefRefPtr browser, + uint64_t prompt_id, + const CefString& requesting_origin, + uint32_t requested_permissions, + CefRefPtr callback) override; + +private: + BrowserRole role_; + BrowserClientDelegate* delegate_ = nullptr; + + IMPLEMENT_REFCOUNTING(NebulaBrowserClient); +}; + +} // namespace nebula::cef diff --git a/src/cef/nebula_app.cpp b/src/cef/nebula_app.cpp new file mode 100644 index 0000000..1cc2c43 --- /dev/null +++ b/src/cef/nebula_app.cpp @@ -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 object, + const CefV8ValueList& arguments, + CefRefPtr& 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 context = CefV8Context::GetCurrentContext(); + CefRefPtr browser = context ? context->GetBrowser() : nullptr; + CefRefPtr frame = context ? context->GetFrame() : nullptr; + if (!browser || !frame) { + exception = "No CEF frame is available for native messaging."; + return true; + } + + CefRefPtr message = CefProcessMessage::Create(kChromeCommandMessage); + CefRefPtr 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 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 browser, + CefRefPtr frame, + CefRefPtr context) { + CEF_REQUIRE_RENDERER_THREAD(); + UNREFERENCED_PARAMETER(browser); + UNREFERENCED_PARAMETER(frame); + + CefRefPtr global = context->GetGlobal(); + CefRefPtr 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 diff --git a/src/cef/nebula_app.h b/src/cef/nebula_app.h new file mode 100644 index 0000000..8089795 --- /dev/null +++ b/src/cef/nebula_app.h @@ -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 GetRenderProcessHandler() override { return this; } + + void OnBeforeCommandLineProcessing(const CefString& process_type, + CefRefPtr command_line) override; + + void OnContextCreated(CefRefPtr browser, + CefRefPtr frame, + CefRefPtr context) override; + +private: + IMPLEMENT_REFCOUNTING(NebulaApp); +}; + +} // namespace nebula::cef diff --git a/src/ui/paths.cpp b/src/ui/paths.cpp new file mode 100644 index 0000000..d071255 --- /dev/null +++ b/src/ui/paths.cpp @@ -0,0 +1,121 @@ +#include "ui/paths.h" + +#include + +#include +#include + +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(value.size()), + nullptr, 0, nullptr, nullptr); + std::string result(size, '\0'); + WideCharToMultiByte( + CP_UTF8, 0, value.data(), static_cast(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(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 diff --git a/src/ui/paths.h b/src/ui/paths.h new file mode 100644 index 0000000..58348bd --- /dev/null +++ b/src/ui/paths.h @@ -0,0 +1,21 @@ +#pragma once + +#include +#include + +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 diff --git a/src/window/nebula_window.cpp b/src/window/nebula_window.cpp new file mode 100644 index 0000000..f6e57d3 --- /dev/null +++ b/src/window/nebula_window.cpp @@ -0,0 +1,426 @@ +#include "window/nebula_window.h" + +#include +#include + +#include + +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(ScaleForDpi(1400), work_area.right - work_area.left); + const int height = std::min(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(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(this)); +} + +LRESULT CALLBACK NebulaWindow::StaticWndProc(HWND hwnd, UINT message, WPARAM wparam, LPARAM lparam) { + NebulaWindow* self = nullptr; + + if (message == WM_NCCREATE) { + auto* create = reinterpret_cast(lparam); + self = static_cast(create->lpCreateParams); + SetWindowLongPtrW(hwnd, GWLP_USERDATA, reinterpret_cast(self)); + self->hwnd_ = hwnd; + } else { + self = reinterpret_cast(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(GetPropW(hwnd, kChildFrameHitTestOldProcProp)); + + if (message == WM_NCHITTEST) { + const auto parent = reinterpret_cast(GetPropW(hwnd, kChildFrameHitTestParentProp)); + auto* self = parent ? reinterpret_cast(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(GetPropW(hwnd, kChildFrameHitTestParentProp)); + auto* self = parent ? reinterpret_cast(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(GetPropW(hwnd, kChildFrameHitTestParentProp)); + auto* self = parent ? reinterpret_cast(GetWindowLongPtrW(parent, GWLP_USERDATA)) : nullptr; + POINT point = {}; + if (self && GetCursorPos(&point) && SetResizeCursor(self->HitTestPoint(point))) { + return 0; + } + } + + if (message == WM_NCLBUTTONDOWN && IsResizeHit(static_cast(wparam))) { + const auto parent = reinterpret_cast(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(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(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(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( + SetWindowLongPtrW(child, GWLP_WNDPROC, reinterpret_cast(&NebulaWindow::ChildFrameWndProc))); + if (old_proc) { + SetPropW(child, kChildFrameHitTestOldProcProp, reinterpret_cast(old_proc)); + } else { + RemovePropW(child, kChildFrameHitTestParentProp); + } +} + +int NebulaWindow::ScaleForDpi(int value) const { + return MulDiv(value, static_cast(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 diff --git a/src/window/nebula_window.h b/src/window/nebula_window.h new file mode 100644 index 0000000..83bcb36 --- /dev/null +++ b/src/window/nebula_window.h @@ -0,0 +1,61 @@ +#pragma once + +#include + +#include + +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 diff --git a/ui/css/chrome.css b/ui/css/chrome.css new file mode 100644 index 0000000..5970cfa --- /dev/null +++ b/ui/css/chrome.css @@ -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 with ; 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); + } +} diff --git a/ui/js/chrome.js b/ui/js/chrome.js new file mode 100644 index 0000000..ee5a47b --- /dev/null +++ b/ui/js/chrome.js @@ -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 = ''; + + 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 = ``; + 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); +}); diff --git a/ui/pages/chrome.html b/ui/pages/chrome.html new file mode 100644 index 0000000..c12d969 --- /dev/null +++ b/ui/pages/chrome.html @@ -0,0 +1,74 @@ + + + + + + Nebula Chrome + + + +
+
+
+ + Nebula +
+ +
+ + +
+ +
+ + + +
+
+ +
+ + + + + +
+
+ + +
+ + + +
+
+ + + + + +