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.