From ce92b3841f349c67b509dbd5bdf1f761a81edb59 Mon Sep 17 00:00:00 2001 From: Andrew Zambazos <62979495+Bobbybear007@users.noreply.github.com> Date: Wed, 20 May 2026 21:05:59 +1200 Subject: [PATCH] Add default browser & external-open support Introduce default-browser integration and external open handling across platforms. Added platform/default_browser.h with a Windows implementation (registry registration + settings UI invocation) and mac/linux stubs. Exposed new commands from the renderer (check-default-browser / set-default-browser) and implemented request/response plumbing in NebulaController (SendDefaultBrowserResult) and BrowserClient. Added UI controls and JS helpers in settings and setup pages to check and prompt the user to make Nebula the default browser. Also added single-instance launch target handling: command-line URL normalization, passing/consolidating the launch target, forwarding it to an existing window on Windows via WM_COPYDATA, and exposing OnExternalOpenRequested on the window/controller. Implemented delayed navigation (CefTask) to safely load pending initial URLs after CEF initialization. Updated CMakeLists and platform startup signatures to include and accept the new files/parameters. --- CMakeLists.txt | 3 + src/app/nebula_controller.cpp | 107 +++++++++++ src/app/nebula_controller.h | 7 + src/app/run.cpp | 117 +++++++++++- src/cef/browser_client.cpp | 6 +- src/platform/default_browser.h | 9 + src/platform/linux/default_browser_linux.cpp | 17 ++ src/platform/linux/startup_linux.cpp | 4 +- src/platform/mac/default_browser_mac.mm | 17 ++ src/platform/mac/startup_mac.mm | 4 +- src/platform/startup.h | 4 +- src/platform/win/default_browser_win.cpp | 176 +++++++++++++++++++ src/platform/win/nebula_window_win.cpp | 47 +++++ src/platform/win/startup_win.cpp | 54 +++++- src/window/nebula_window.h | 1 + ui/js/settings.js | 105 +++++++++++ ui/js/setup.js | 78 +++++++- ui/pages/settings.html | 9 + 18 files changed, 748 insertions(+), 17 deletions(-) create mode 100644 src/platform/default_browser.h create mode 100644 src/platform/linux/default_browser_linux.cpp create mode 100644 src/platform/mac/default_browser_mac.mm create mode 100644 src/platform/win/default_browser_win.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 3d0e2a1..015c103 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -54,6 +54,7 @@ set(NEBULA_COMMON_SOURCES if(OS_WINDOWS) set(NEBULA_PLATFORM_SOURCES + src/platform/win/default_browser_win.cpp src/platform/win/paths_win.cpp src/platform/win/startup_win.cpp src/platform/win/browser_host_win.cpp @@ -61,6 +62,7 @@ if(OS_WINDOWS) ) elseif(OS_MACOSX) set(NEBULA_PLATFORM_SOURCES + src/platform/mac/default_browser_mac.mm src/platform/mac/paths_mac.cpp src/platform/mac/startup_mac.mm src/platform/mac/browser_host_mac.mm @@ -75,6 +77,7 @@ elseif(OS_MACOSX) ) elseif(OS_LINUX) set(NEBULA_PLATFORM_SOURCES + src/platform/linux/default_browser_linux.cpp src/platform/linux/paths_linux.cpp src/platform/linux/startup_linux.cpp src/platform/linux/browser_host_linux.cpp diff --git a/src/app/nebula_controller.cpp b/src/app/nebula_controller.cpp index c5b8a98..60e3ed0 100644 --- a/src/app/nebula_controller.cpp +++ b/src/app/nebula_controller.cpp @@ -13,8 +13,10 @@ #include "include/cef_app.h" #include "include/cef_browser.h" #include "include/cef_cookie.h" +#include "include/cef_task.h" #include "include/wrapper/cef_helpers.h" #include "platform/browser_host.h" +#include "platform/default_browser.h" #include "ui/paths.h" namespace nebula::app { @@ -203,6 +205,7 @@ void NebulaController::OnWindowCreated() { } else if (initial_url_.empty()) { tabs_.CreateInitialTab(nebula::ui::GetHomeUrl()); } else { + // Create tab directly with the external URL - no delayed navigation needed tabs_.CreateInitialTab(initial_url_); } PersistSession(); @@ -231,6 +234,25 @@ void NebulaController::OnWindowCloseRequested() { BeginShutdown(); } +void NebulaController::OnExternalOpenRequested(const std::string& target) { + if (target.empty()) { + return; + } + + if (!chrome_ready_ && !big_picture_ready_) { + pending_initial_navigation_ = target; + return; + } + + auto* tab = tabs_.ActiveTab(); + if (tab && tab->browser) { + tabs_.LoadURL(target); + return; + } + + CreateNewTab(target); +} + void NebulaController::BeginShutdown() { if (closing_) { if (window_ && window_->native_handle()) { @@ -297,6 +319,13 @@ void NebulaController::OnBrowserCreated(nebula::cef::BrowserRole role, CefRefPtr if (const auto* tab = tabs_.ActiveTab()) { SendChromeState(*tab); } + + // If we have a pending initial navigation, we might need to load it now + // if the content browser is already ready, or we'll let the content browser + // handle it when it's created. + if (!pending_initial_navigation_.empty() && tabs_.ActiveTab() && tabs_.ActiveTab()->browser) { + LoadPendingNavigationDelayed(); + } } else if (role == nebula::cef::BrowserRole::BigPicture) { big_picture_browser_ = browser; big_picture_ready_ = true; @@ -311,6 +340,12 @@ void NebulaController::OnBrowserCreated(nebula::cef::BrowserRole role, CefRefPtr SendMenuPopupZoom(); } else { tabs_.SetActiveBrowser(browser); + + // Only load the pending navigation if the UI (chrome or big picture) is ready. + // Otherwise, wait for the UI to be ready. + if (!pending_initial_navigation_.empty() && (chrome_ready_ || big_picture_ready_)) { + LoadPendingNavigationDelayed(); + } } ResizeBrowsers(); @@ -399,6 +434,15 @@ void NebulaController::OnChromeCommand(const std::string& command, const std::st SendThemeToChromeSurfaces(payload); } else if (command == "complete-first-run") { CompleteFirstRunSetup(); + } else if (command == "check-default-browser") { + SendDefaultBrowserResult(payload, true, false); + } else if (command == "set-default-browser") { + const bool success = nebula::platform::RequestDefaultBrowser(); + SendDefaultBrowserResult( + payload, + success, + success && !nebula::platform::IsDefaultBrowser(), + success ? std::string{} : "Unable to register Nebula as a browser."); } else if (command == "clear-site-history") { site_history_.clear(); SaveSiteHistory(site_history_); @@ -991,6 +1035,34 @@ void NebulaController::CompleteFirstRunSetup() { ResizeBrowsers(); } +void NebulaController::SendDefaultBrowserResult(const std::string& request_id, + bool success, + bool needs_user_action, + const std::string& error) { + auto* tab = tabs_.ActiveTab(); + if (!tab || !tab->browser) { + return; + } + + std::string detail = "{"; + detail += "\"requestId\":\"" + nebula::browser::JsonEscape(request_id) + "\""; + detail += ",\"success\":"; + detail += success ? "true" : "false"; + detail += ",\"isDefault\":"; + detail += nebula::platform::IsDefaultBrowser() ? "true" : "false"; + detail += ",\"needsUserAction\":"; + detail += needs_user_action ? "true" : "false"; + if (!error.empty()) { + detail += ",\"error\":\"" + nebula::browser::JsonEscape(error) + "\""; + } + detail += "}"; + + const std::string script = + "window.dispatchEvent(new CustomEvent('nebula-default-browser-result',{detail:" + + detail + "}));"; + tab->browser->GetMainFrame()->ExecuteJavaScript(script, tab->url, 0); +} + void NebulaController::ResizeBrowsers() { if (!window_) { return; @@ -1230,4 +1302,39 @@ bool NebulaController::ForgetClosingTabBrowser(CefRefPtr browser) { return true; } +namespace { + +class DelayedNavigationTask : public CefTask { +public: + DelayedNavigationTask(nebula::browser::TabManager* tabs, std::string url) + : tabs_(tabs), url_(std::move(url)) {} + + void Execute() override { + if (tabs_) { + tabs_->LoadURL(url_); + } + } + +private: + nebula::browser::TabManager* tabs_; + std::string url_; + + IMPLEMENT_REFCOUNTING(DelayedNavigationTask); +}; + +} // namespace + +void NebulaController::LoadPendingNavigationDelayed() { + if (pending_initial_navigation_.empty()) { + return; + } + + const std::string target = std::move(pending_initial_navigation_); + pending_initial_navigation_.clear(); + + // Post a delayed task to load the URL after CEF has fully initialized. + // This gives CEF time to complete internal setup after browser creation. + CefPostDelayedTask(TID_UI, new DelayedNavigationTask(&tabs_, target), 250); +} + } // namespace nebula::app diff --git a/src/app/nebula_controller.h b/src/app/nebula_controller.h index 6ceb03a..e902a79 100644 --- a/src/app/nebula_controller.h +++ b/src/app/nebula_controller.h @@ -27,6 +27,7 @@ public: void OnWindowCreated() override; void OnWindowResized(const nebula::window::BrowserLayout& layout) override; void OnWindowCloseRequested() override; + void OnExternalOpenRequested(const std::string& target) override; void OnActiveTabChanged(const nebula::browser::NebulaTab& tab) override; @@ -69,6 +70,10 @@ private: void SetBigPictureBrowseVisible(bool visible); void SetContentFullscreen(bool fullscreen); void CompleteFirstRunSetup(); + void SendDefaultBrowserResult(const std::string& request_id, + bool success, + bool needs_user_action, + const std::string& error = {}); void ResizeBrowsers(); void SendChromeState(const nebula::browser::NebulaTab& tab); void SendBigPictureState(const nebula::browser::NebulaTab& tab); @@ -80,9 +85,11 @@ private: void BeginShutdown(); void MaybeFinishShutdown(); bool ForgetClosingTabBrowser(CefRefPtr browser); + void LoadPendingNavigationDelayed(); nebula::platform::AppStartup startup_; std::string initial_url_; + std::string pending_initial_navigation_; LaunchOptions launch_options_; bool closing_ = false; bool chrome_ready_ = false; diff --git a/src/app/run.cpp b/src/app/run.cpp index 3fb370a..6b24a86 100644 --- a/src/app/run.cpp +++ b/src/app/run.cpp @@ -1,13 +1,115 @@ #include "app/run.h" #include "app/nebula_controller.h" +#include "browser/url_utils.h" #include "cef/nebula_app.h" #include "include/cef_app.h" #include "include/cef_command_line.h" +#include "platform/default_browser.h" #include "platform/startup.h" #include "ui/paths.h" +#include +#include +#include +#include +#include + +#if defined(_WIN32) +#include +#endif + namespace nebula::app { +namespace { + +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; +} + +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 StartsWithKnownScheme(const std::string& value) { + const std::string lower = ToLowerAscii(value); + return lower.starts_with("http://") || + lower.starts_with("https://") || + lower.starts_with("file:") || + lower.starts_with("data:") || + lower.starts_with("blob:") || + lower.starts_with("chrome:") || + lower.starts_with("nebula://"); +} + +std::filesystem::path PathFromUtf8(const std::string& value) { +#if defined(_WIN32) + const int size = MultiByteToWideChar( + CP_UTF8, 0, value.data(), static_cast(value.size()), nullptr, 0); + if (size <= 0) { + return std::filesystem::path(value); + } + + std::wstring wide(size, L'\0'); + MultiByteToWideChar( + CP_UTF8, 0, value.data(), static_cast(value.size()), wide.data(), size); + return std::filesystem::path(wide); +#else + return std::filesystem::path(value); +#endif +} + +std::string NormalizeLaunchTarget(std::string target) { + target = Trim(std::move(target)); + if (target.empty() || nebula::ui::IsChromiumNewTabUrl(target)) { + return target.empty() ? std::string{} : nebula::ui::GetHomeUrl(); + } + + if (StartsWithKnownScheme(target)) { + return target; + } + + const std::filesystem::path path = PathFromUtf8(target); + std::error_code ec; + if (!path.empty() && std::filesystem::exists(path, ec) && !ec) { + const std::filesystem::path absolute_path = std::filesystem::absolute(path, ec); + return nebula::ui::FilePathToUrl(ec ? path : absolute_path); + } + + return nebula::browser::NormalizeNavigationInput(target); +} + +std::string GetLaunchTarget(CefRefPtr command_line) { + if (!command_line) { + return {}; + } + + std::string target = command_line->GetSwitchValue("url"); + if (!target.empty()) { + return NormalizeLaunchTarget(std::move(target)); + } + + std::vector arguments; + command_line->GetArguments(arguments); + for (const auto& argument : arguments) { + target = argument.ToString(); + if (!Trim(target).empty()) { + return NormalizeLaunchTarget(std::move(target)); + } + } + + return {}; +} + +} // namespace int RunNebula(const nebula::platform::AppStartup& startup, LaunchOptions options) { nebula::platform::PrepareApp(); @@ -20,9 +122,14 @@ int RunNebula(const nebula::platform::AppStartup& startup, LaunchOptions options return subprocess_exit_code; } - if (!nebula::platform::TryAcquireSingleInstance()) { + CefRefPtr command_line = CefCommandLine::CreateCommandLine(); + nebula::platform::InitCommandLine(command_line, startup); + std::string initial_url = GetLaunchTarget(command_line); + + if (!nebula::platform::TryAcquireSingleInstance(initial_url)) { return 0; } + nebula::platform::EnsureDefaultBrowserRegistration(); CefSettings settings; settings.no_sandbox = true; @@ -33,14 +140,6 @@ int RunNebula(const nebula::platform::AppStartup& startup, LaunchOptions options return CefGetExitCode(); } - CefRefPtr command_line = CefCommandLine::CreateCommandLine(); - nebula::platform::InitCommandLine(command_line, startup); - - std::string initial_url = command_line->GetSwitchValue("url"); - if (!initial_url.empty() && nebula::ui::IsChromiumNewTabUrl(initial_url)) { - initial_url = nebula::ui::GetHomeUrl(); - } - NebulaController controller(startup, std::move(initial_url), options); const bool created = controller.Create(); if (created) { diff --git a/src/cef/browser_client.cpp b/src/cef/browser_client.cpp index 40c6507..0494ed6 100644 --- a/src/cef/browser_client.cpp +++ b/src/cef/browser_client.cpp @@ -111,10 +111,14 @@ bool NebulaBrowserClient::OnProcessMessageReceived(CefRefPtr browser const bool allowed_settings_command = IsSettingsFrame(frame) && (command == "navigate" || command == "new-tab" || + command == "check-default-browser" || + command == "set-default-browser" || command == "clear-site-history" || command == "clear-search-history"); const bool allowed_setup_command = - command == "complete-first-run" && IsSetupFrame(frame); + IsSetupFrame(frame) && (command == "complete-first-run" || + command == "check-default-browser" || + command == "set-default-browser"); const bool allowed_big_picture_command = IsBigPictureFrame(frame) && command == "exit-bigpicture"; if (!allowed_insecure_command && !allowed_settings_command && diff --git a/src/platform/default_browser.h b/src/platform/default_browser.h new file mode 100644 index 0000000..9827247 --- /dev/null +++ b/src/platform/default_browser.h @@ -0,0 +1,9 @@ +#pragma once + +namespace nebula::platform { + +bool IsDefaultBrowser(); +bool EnsureDefaultBrowserRegistration(); +bool RequestDefaultBrowser(); + +} // namespace nebula::platform diff --git a/src/platform/linux/default_browser_linux.cpp b/src/platform/linux/default_browser_linux.cpp new file mode 100644 index 0000000..05ba9bb --- /dev/null +++ b/src/platform/linux/default_browser_linux.cpp @@ -0,0 +1,17 @@ +#include "platform/default_browser.h" + +namespace nebula::platform { + +bool IsDefaultBrowser() { + return false; +} + +bool EnsureDefaultBrowserRegistration() { + return false; +} + +bool RequestDefaultBrowser() { + return false; +} + +} // namespace nebula::platform diff --git a/src/platform/linux/startup_linux.cpp b/src/platform/linux/startup_linux.cpp index 9a402be..e52e12c 100644 --- a/src/platform/linux/startup_linux.cpp +++ b/src/platform/linux/startup_linux.cpp @@ -18,7 +18,9 @@ int g_single_instance_lock = -1; void PrepareApp() {} -bool TryAcquireSingleInstance() { +bool TryAcquireSingleInstance(const std::string& launch_target) { + NEBULA_UNUSED(launch_target); + const auto lock_path = nebula::ui::GetUserDataDirectory() / ".nebula_instance.lock"; std::error_code ec; std::filesystem::create_directories(lock_path.parent_path(), ec); diff --git a/src/platform/mac/default_browser_mac.mm b/src/platform/mac/default_browser_mac.mm new file mode 100644 index 0000000..05ba9bb --- /dev/null +++ b/src/platform/mac/default_browser_mac.mm @@ -0,0 +1,17 @@ +#include "platform/default_browser.h" + +namespace nebula::platform { + +bool IsDefaultBrowser() { + return false; +} + +bool EnsureDefaultBrowserRegistration() { + return false; +} + +bool RequestDefaultBrowser() { + return false; +} + +} // namespace nebula::platform diff --git a/src/platform/mac/startup_mac.mm b/src/platform/mac/startup_mac.mm index f64a0aa..9f2d5e9 100644 --- a/src/platform/mac/startup_mac.mm +++ b/src/platform/mac/startup_mac.mm @@ -26,7 +26,9 @@ void PrepareApp() { } } -bool TryAcquireSingleInstance() { +bool TryAcquireSingleInstance(const std::string& launch_target) { + NEBULA_UNUSED(launch_target); + const auto lock_path = nebula::ui::GetUserDataDirectory() / ".nebula_instance.lock"; std::error_code ec; std::filesystem::create_directories(lock_path.parent_path(), ec); diff --git a/src/platform/startup.h b/src/platform/startup.h index 2186ebd..d89b24f 100644 --- a/src/platform/startup.h +++ b/src/platform/startup.h @@ -3,10 +3,12 @@ #include "include/cef_app.h" #include "platform/types.h" +#include + namespace nebula::platform { void PrepareApp(); -bool TryAcquireSingleInstance(); +bool TryAcquireSingleInstance(const std::string& launch_target = {}); CefMainArgs MakeMainArgs(const AppStartup& startup); void InitCommandLine(CefRefPtr command_line, const AppStartup& startup); void ConfigureCefSettings(CefSettings& settings); diff --git a/src/platform/win/default_browser_win.cpp b/src/platform/win/default_browser_win.cpp new file mode 100644 index 0000000..bebe64d --- /dev/null +++ b/src/platform/win/default_browser_win.cpp @@ -0,0 +1,176 @@ +#include "platform/default_browser.h" + +#include +#include + +#include +#include +#include + +namespace nebula::platform { +namespace { + +constexpr wchar_t kAppName[] = L"Nebula Browser"; +constexpr wchar_t kClientKeyName[] = L"NebulaBrowser"; +constexpr wchar_t kRegisteredApplicationsKey[] = L"Software\\RegisteredApplications"; +constexpr wchar_t kClientRootKey[] = L"Software\\Clients\\StartMenuInternet\\NebulaBrowser"; +constexpr wchar_t kFileAssociationsKey[] = + L"Software\\Clients\\StartMenuInternet\\NebulaBrowser\\Capabilities\\FileAssociations"; +constexpr wchar_t kUrlAssociationsKey[] = + L"Software\\Clients\\StartMenuInternet\\NebulaBrowser\\Capabilities\\URLAssociations"; +constexpr wchar_t kHttpUserChoiceKey[] = + L"Software\\Microsoft\\Windows\\Shell\\Associations\\UrlAssociations\\http\\UserChoice"; +constexpr wchar_t kHttpsUserChoiceKey[] = + L"Software\\Microsoft\\Windows\\Shell\\Associations\\UrlAssociations\\https\\UserChoice"; +constexpr std::wstring_view kWebFileExtensions[] = { + L".htm", + L".html", + L".shtml", + L".xht", + L".xhtml", + L".svg", + L".webp", +}; + +std::wstring CurrentExecutablePath() { + std::wstring path(MAX_PATH, L'\0'); + DWORD length = 0; + while (true) { + length = GetModuleFileNameW(nullptr, path.data(), static_cast(path.size())); + if (length == 0) { + return {}; + } + if (length < path.size() - 1) { + path.resize(length); + return path; + } + path.resize(path.size() * 2); + } +} + +std::wstring Quote(std::wstring_view value) { + std::wstring quoted = L"\""; + quoted += value; + quoted += L"\""; + return quoted; +} + +bool SetStringValue(HKEY root, + const std::wstring& subkey, + const wchar_t* value_name, + const std::wstring& value) { + HKEY key = nullptr; + const LSTATUS status = RegCreateKeyExW( + root, subkey.c_str(), 0, nullptr, 0, KEY_SET_VALUE, nullptr, &key, nullptr); + if (status != ERROR_SUCCESS) { + return false; + } + + const DWORD byte_size = static_cast((value.size() + 1) * sizeof(wchar_t)); + const LSTATUS set_status = RegSetValueExW( + key, + value_name, + 0, + REG_SZ, + reinterpret_cast(value.c_str()), + byte_size); + RegCloseKey(key); + return set_status == ERROR_SUCCESS; +} + +std::wstring ReadStringValue(HKEY root, const wchar_t* subkey, const wchar_t* value_name) { + HKEY key = nullptr; + if (RegOpenKeyExW(root, subkey, 0, KEY_QUERY_VALUE, &key) != ERROR_SUCCESS) { + return {}; + } + + DWORD type = 0; + DWORD byte_size = 0; + if (RegQueryValueExW(key, value_name, nullptr, &type, nullptr, &byte_size) != ERROR_SUCCESS || + type != REG_SZ || byte_size == 0) { + RegCloseKey(key); + return {}; + } + + std::wstring value(byte_size / sizeof(wchar_t), L'\0'); + const LSTATUS status = RegQueryValueExW( + key, value_name, nullptr, nullptr, reinterpret_cast(value.data()), &byte_size); + RegCloseKey(key); + if (status != ERROR_SUCCESS) { + return {}; + } + + while (!value.empty() && value.back() == L'\0') { + value.pop_back(); + } + return value; +} + +bool RegisterDefaultBrowserCapabilities() { + const std::wstring exe_path = CurrentExecutablePath(); + if (exe_path.empty()) { + return false; + } + + const std::wstring exe_name = std::filesystem::path(exe_path).filename().wstring(); + const std::wstring command = Quote(exe_path) + L" --url=" + Quote(L"%1"); + bool ok = true; + + ok &= SetStringValue(HKEY_CURRENT_USER, kRegisteredApplicationsKey, kAppName, + std::wstring(kClientRootKey) + L"\\Capabilities"); + ok &= SetStringValue(HKEY_CURRENT_USER, kClientRootKey, nullptr, kAppName); + ok &= SetStringValue(HKEY_CURRENT_USER, std::wstring(kClientRootKey) + L"\\DefaultIcon", + nullptr, exe_path + L",0"); + ok &= SetStringValue(HKEY_CURRENT_USER, std::wstring(kClientRootKey) + L"\\shell\\open\\command", + nullptr, Quote(exe_path)); + ok &= SetStringValue(HKEY_CURRENT_USER, std::wstring(kClientRootKey) + L"\\Capabilities", + L"ApplicationName", kAppName); + ok &= SetStringValue(HKEY_CURRENT_USER, std::wstring(kClientRootKey) + L"\\Capabilities", + L"ApplicationDescription", L"Nebula Browser"); + for (std::wstring_view extension : kWebFileExtensions) { + const std::wstring extension_name(extension); + ok &= SetStringValue(HKEY_CURRENT_USER, kFileAssociationsKey, extension_name.c_str(), + kClientKeyName); + } + ok &= SetStringValue(HKEY_CURRENT_USER, kUrlAssociationsKey, L"http", kClientKeyName); + ok &= SetStringValue(HKEY_CURRENT_USER, kUrlAssociationsKey, L"https", kClientKeyName); + ok &= SetStringValue(HKEY_CURRENT_USER, L"Software\\Classes\\NebulaBrowser", nullptr, + L"Nebula Browser HTML Document"); + ok &= SetStringValue(HKEY_CURRENT_USER, L"Software\\Classes\\NebulaBrowser", L"URL Protocol", + L""); + ok &= SetStringValue(HKEY_CURRENT_USER, L"Software\\Classes\\NebulaBrowser\\DefaultIcon", + nullptr, exe_path + L",0"); + ok &= SetStringValue(HKEY_CURRENT_USER, L"Software\\Classes\\NebulaBrowser\\shell\\open\\command", + nullptr, command); + const std::wstring application_key = std::wstring(L"Software\\Classes\\Applications\\") + exe_name; + ok &= SetStringValue(HKEY_CURRENT_USER, application_key, + L"ApplicationName", kAppName); + ok &= SetStringValue(HKEY_CURRENT_USER, + application_key + L"\\shell\\open\\command", + nullptr, command); + + return ok; +} + +void OpenDefaultAppsSettings() { + ShellExecuteW(nullptr, L"open", L"ms-settings:defaultapps", nullptr, nullptr, SW_SHOWNORMAL); +} + +} // namespace + +bool IsDefaultBrowser() { + return ReadStringValue(HKEY_CURRENT_USER, kHttpUserChoiceKey, L"ProgId") == kClientKeyName && + ReadStringValue(HKEY_CURRENT_USER, kHttpsUserChoiceKey, L"ProgId") == kClientKeyName; +} + +bool EnsureDefaultBrowserRegistration() { + return RegisterDefaultBrowserCapabilities(); +} + +bool RequestDefaultBrowser() { + const bool registered = RegisterDefaultBrowserCapabilities(); + OpenDefaultAppsSettings(); + return registered; +} + +} // namespace nebula::platform diff --git a/src/platform/win/nebula_window_win.cpp b/src/platform/win/nebula_window_win.cpp index 7b77d74..c9c89c2 100644 --- a/src/platform/win/nebula_window_win.cpp +++ b/src/platform/win/nebula_window_win.cpp @@ -5,6 +5,7 @@ #include #include +#include namespace nebula::window { namespace { @@ -13,6 +14,7 @@ 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 ULONG_PTR kOpenTargetCopyDataId = 0x4E42554CUL; constexpr int kTitleRowHeightDip = 42; constexpr int kWindowControlWidthDip = 46; constexpr int kWindowControlCount = 3; @@ -122,6 +124,37 @@ RECT ToNativeRect(const platform::Rect& rect) { }; } +std::string WideToUtf8(std::wstring_view value) { + if (value.empty()) { + return {}; + } + + const int size = WideCharToMultiByte( + CP_UTF8, + 0, + value.data(), + static_cast(value.size()), + nullptr, + 0, + nullptr, + nullptr); + if (size <= 0) { + return {}; + } + + std::string result(size, '\0'); + WideCharToMultiByte( + CP_UTF8, + 0, + value.data(), + static_cast(value.size()), + result.data(), + size, + nullptr, + nullptr); + return result; +} + } // namespace struct nebula::window::NebulaWindowImpl { @@ -354,6 +387,20 @@ LRESULT nebula::window::NebulaWindowImpl::WndProc(UINT message, WPARAM wparam, L } break; + case WM_COPYDATA: { + const auto* copy_data = reinterpret_cast(lparam); + if (copy_data && copy_data->dwData == kOpenTargetCopyDataId && + copy_data->lpData && copy_data->cbData >= sizeof(wchar_t)) { + const auto* text = static_cast(copy_data->lpData); + const size_t char_count = (copy_data->cbData / sizeof(wchar_t)) - 1; + if (delegate) { + delegate->OnExternalOpenRequested(WideToUtf8(std::wstring_view(text, char_count))); + } + return TRUE; + } + break; + } + case WM_DESTROY: hwnd = nullptr; return 0; diff --git a/src/platform/win/startup_win.cpp b/src/platform/win/startup_win.cpp index 2c2298b..d99422d 100644 --- a/src/platform/win/startup_win.cpp +++ b/src/platform/win/startup_win.cpp @@ -2,6 +2,8 @@ #include +#include + #include "include/cef_command_line.h" #include "ui/paths.h" @@ -9,6 +11,8 @@ namespace nebula::platform { namespace { constexpr wchar_t kMainInstanceMutexName[] = L"Local\\NebulaBrowserMainInstance"; +constexpr wchar_t kWindowClassName[] = L"NebulaBrowserWindow"; +constexpr ULONG_PTR kOpenTargetCopyDataId = 0x4E42554CUL; class ScopedHandle { public: @@ -28,15 +32,61 @@ private: HANDLE handle_ = nullptr; }; +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); + if (size <= 0) { + return {}; + } + + std::wstring result(size, L'\0'); + MultiByteToWideChar( + CP_UTF8, 0, value.data(), static_cast(value.size()), result.data(), size); + return result; +} + +void ForwardLaunchTargetToExistingWindow(const std::string& launch_target) { + if (launch_target.empty()) { + return; + } + + HWND existing_window = FindWindowW(kWindowClassName, nullptr); + if (!existing_window) { + return; + } + + const std::wstring target = Utf8ToWide(launch_target); + if (target.empty()) { + return; + } + + COPYDATASTRUCT data = {}; + data.dwData = kOpenTargetCopyDataId; + data.cbData = static_cast((target.size() + 1) * sizeof(wchar_t)); + data.lpData = const_cast(target.c_str()); + SendMessageW(existing_window, WM_COPYDATA, 0, reinterpret_cast(&data)); + + ShowWindow(existing_window, SW_SHOWNORMAL); + SetForegroundWindow(existing_window); +} + } // namespace void PrepareApp() { SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2); } -bool TryAcquireSingleInstance() { +bool TryAcquireSingleInstance(const std::string& launch_target) { static ScopedHandle mutex(CreateMutexW(nullptr, TRUE, kMainInstanceMutexName)); - return !(mutex.valid() && GetLastError() == ERROR_ALREADY_EXISTS); + const bool already_running = mutex.valid() && GetLastError() == ERROR_ALREADY_EXISTS; + if (already_running) { + ForwardLaunchTargetToExistingWindow(launch_target); + } + return !already_running; } CefMainArgs MakeMainArgs(const AppStartup& startup) { diff --git a/src/window/nebula_window.h b/src/window/nebula_window.h index 76d03ca..1bad320 100644 --- a/src/window/nebula_window.h +++ b/src/window/nebula_window.h @@ -17,6 +17,7 @@ public: virtual void OnWindowCreated() = 0; virtual void OnWindowResized(const BrowserLayout& layout) = 0; virtual void OnWindowCloseRequested() = 0; + virtual void OnExternalOpenRequested(const std::string& target) = 0; }; class NebulaWindow { diff --git a/ui/js/settings.js b/ui/js/settings.js index 2040377..afb7084 100644 --- a/ui/js/settings.js +++ b/ui/js/settings.js @@ -12,6 +12,109 @@ const HOME_SEARCH_Y_KEY = 'nebula-home-search-y'; // number (vh) const HOME_BOOKMARKS_Y_KEY = 'nebula-home-bookmarks-y'; // number (vh) const HOME_GLANCE_CORNER_KEY = 'nebula-home-glance-corner'; // 'br'|'bl'|'tr'|'tl' const DISPLAY_SCALE_KEY = 'nebula-display-scale'; // number (50-300) +const defaultBrowserRequests = new Map(); +let defaultBrowserRequestId = 0; + +function hasNebulaNativeBridge() { + return !!(window.nebulaNative && typeof window.nebulaNative.postMessage === 'function'); +} + +window.addEventListener('nebula-default-browser-result', (event) => { + const detail = event.detail || {}; + const pending = defaultBrowserRequests.get(detail.requestId); + if (!pending) return; + + defaultBrowserRequests.delete(detail.requestId); + pending.resolve(detail); +}); + +function sendDefaultBrowserRequest(command) { + return new Promise((resolve, reject) => { + if (!hasNebulaNativeBridge()) { + reject(new Error('Native browser integration is unavailable.')); + return; + } + + const requestId = `settings-default-browser-${Date.now()}-${++defaultBrowserRequestId}`; + const timeout = setTimeout(() => { + defaultBrowserRequests.delete(requestId); + reject(new Error('Timed out waiting for default browser status.')); + }, 10000); + + defaultBrowserRequests.set(requestId, { + resolve: (value) => { + clearTimeout(timeout); + resolve(value); + } + }); + + try { + window.nebulaNative.postMessage(command, requestId); + } catch (error) { + defaultBrowserRequests.delete(requestId); + clearTimeout(timeout); + reject(error); + } + }); +} + +async function refreshDefaultBrowserStatus() { + const btn = document.getElementById('set-default-browser-btn'); + const status = document.getElementById('default-browser-status'); + if (!btn || !status) return; + + if (!hasNebulaNativeBridge()) { + btn.disabled = true; + status.textContent = 'Default browser setup is only available in the native app.'; + return; + } + + try { + const result = await sendDefaultBrowserRequest('check-default-browser'); + btn.disabled = !!result.isDefault; + btn.textContent = result.isDefault ? 'Already Default' : 'Make Default Browser'; + status.textContent = result.isDefault + ? 'Nebula is your default browser.' + : 'Nebula is not your default browser.'; + } catch (error) { + console.error('Default browser status error:', error); + status.textContent = 'Unable to check default browser status.'; + } +} + +function attachDefaultBrowserHandler() { + const btn = document.getElementById('set-default-browser-btn'); + const status = document.getElementById('default-browser-status'); + if (!btn || !status) return; + + btn.addEventListener('click', async () => { + btn.disabled = true; + btn.textContent = 'Opening Settings...'; + status.textContent = 'Opening Windows default apps settings...'; + + try { + const result = await sendDefaultBrowserRequest('set-default-browser'); + if (result.isDefault) { + btn.textContent = 'Already Default'; + status.textContent = 'Nebula is your default browser.'; + return; + } + + btn.disabled = false; + btn.textContent = 'Check Again'; + status.textContent = result.success + ? 'Choose Nebula in Windows default apps settings, then check again.' + : (result.error || 'Unable to open default browser settings.'); + } catch (error) { + console.error('Default browser setup error:', error); + btn.disabled = false; + btn.textContent = 'Try Again'; + status.textContent = 'Unable to open default browser settings.'; + } + }); + + refreshDefaultBrowserStatus(); +} function showStatus(message) { if (statusText && statusDiv) { @@ -74,6 +177,8 @@ function attachClearHandler(btn) { // Try attaching immediately, and again on DOMContentLoaded attachClearHandler(clearBtn); window.addEventListener('DOMContentLoaded', () => { + attachDefaultBrowserHandler(); + if (!clearBtn) { clearBtn = document.getElementById('clear-data-btn'); attachClearHandler(clearBtn); diff --git a/ui/js/setup.js b/ui/js/setup.js index d4542ed..7e404d7 100644 --- a/ui/js/setup.js +++ b/ui/js/setup.js @@ -16,6 +16,78 @@ function hasNebulaNativeBridge() { return !!(window.nebulaNative && typeof window.nebulaNative.postMessage === 'function'); } +const defaultBrowserRequests = new Map(); +let defaultBrowserRequestId = 0; + +window.addEventListener('nebula-default-browser-result', (event) => { + const detail = event.detail || {}; + const pending = defaultBrowserRequests.get(detail.requestId); + if (!pending) return; + + defaultBrowserRequests.delete(detail.requestId); + pending.resolve(detail); +}); + +function sendDefaultBrowserRequest(command) { + return new Promise((resolve, reject) => { + if (!hasNebulaNativeBridge()) { + reject(new Error('Native browser integration is unavailable.')); + return; + } + + const requestId = `default-browser-${Date.now()}-${++defaultBrowserRequestId}`; + const timeout = setTimeout(() => { + defaultBrowserRequests.delete(requestId); + reject(new Error('Timed out waiting for default browser status.')); + }, 10000); + + defaultBrowserRequests.set(requestId, { + resolve: (value) => { + clearTimeout(timeout); + resolve(value); + }, + reject: (error) => { + clearTimeout(timeout); + reject(error); + } + }); + + try { + window.nebulaNative.postMessage(command, requestId); + } catch (error) { + defaultBrowserRequests.delete(requestId); + clearTimeout(timeout); + reject(error); + } + }); +} + +function createNebulaNativeApi() { + return { + async getAllThemes() { + return { default: getPresetThemes() }; + }, + async isDefaultBrowser() { + const result = await sendDefaultBrowserRequest('check-default-browser'); + return !!result.isDefault; + }, + async setAsDefaultBrowser() { + return sendDefaultBrowserRequest('set-default-browser'); + }, + async applyTheme(themeId) { + const theme = getThemeById(themeId); + if (theme) { + localStorage.setItem('currentTheme', JSON.stringify(normalizeTheme(theme))); + } + localStorage.setItem('activeThemeName', themeId); + }, + async completeFirstRun(data) { + localStorage.setItem('nebula-first-run-complete', JSON.stringify(data)); + window.nebulaNative.postMessage('complete-first-run', JSON.stringify(data)); + } + }; +} + function getPresetThemes() { if (typeof BrowserCustomizer === 'function') { const customizer = new BrowserCustomizer({ skipInit: true }); @@ -61,7 +133,7 @@ function normalizeTheme(theme) { }; } -const nativeApi = window.api || { +const fallbackApi = { async getAllThemes() { return { default: getPresetThemes() }; }, @@ -86,6 +158,8 @@ const nativeApi = window.api || { } }; +const nativeApi = window.api || (hasNebulaNativeBridge() ? createNebulaNativeApi() : fallbackApi); + // Initialize setup when DOM is ready document.addEventListener('DOMContentLoaded', async () => { console.log('[Setup] Initializing first-time setup...'); @@ -402,7 +476,7 @@ async function setDefaultBrowser() { const result = await nativeApi.setAsDefaultBrowser(); if (result.success) { - const isDefault = await window.api.isDefaultBrowser(); + const isDefault = !!result.isDefault || await nativeApi.isDefaultBrowser(); if (isDefault) { setupState.defaultBrowserSet = true; diff --git a/ui/pages/settings.html b/ui/pages/settings.html index 5dc4795..a981610 100644 --- a/ui/pages/settings.html +++ b/ui/pages/settings.html @@ -45,6 +45,15 @@ +
+

Default Browser

+

Use Nebula Browser for web links opened from other apps.

+
+ + Checking status... +
+
+

Weather Display

Choose how temperature is displayed on the Home page weather card.