From 924529807f69f84a10735e50291c0a4ab057312e Mon Sep 17 00:00:00 2001 From: Andrew Zambazos <62979495+Bobbybear007@users.noreply.github.com> Date: Sun, 12 Jul 2026 20:21:32 +1200 Subject: [PATCH] Add unpacked extension support Adds an extension manager that loads unpacked extensions from the user data extensions directory, persists enabled state, and injects matching content scripts on page load. The settings Plugins page now shows installed extensions, supports reload/toggle actions, and includes an example Hello Nebula extension. --- CMakeLists.txt | 1 + examples/extensions/hello-nebula/README.txt | 10 + examples/extensions/hello-nebula/content.css | 20 + examples/extensions/hello-nebula/content.js | 15 + .../extensions/hello-nebula/manifest.json | 15 + src/app/nebula_controller.cpp | 60 ++ src/app/nebula_controller.h | 3 + src/cef/browser_client.cpp | 4 +- src/extensions/extension.h | 30 + src/extensions/extension_manager.cpp | 584 ++++++++++++++++++ src/extensions/extension_manager.h | 32 + src/ui/paths.cpp | 17 + src/ui/paths.h | 2 + ui/js/settings.js | 78 ++- ui/pages/settings.html | 6 +- 15 files changed, 861 insertions(+), 16 deletions(-) create mode 100644 examples/extensions/hello-nebula/README.txt create mode 100644 examples/extensions/hello-nebula/content.css create mode 100644 examples/extensions/hello-nebula/content.js create mode 100644 examples/extensions/hello-nebula/manifest.json create mode 100644 src/extensions/extension.h create mode 100644 src/extensions/extension_manager.cpp create mode 100644 src/extensions/extension_manager.h diff --git a/CMakeLists.txt b/CMakeLists.txt index ed8c343..e911832 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -49,6 +49,7 @@ set(NEBULA_COMMON_SOURCES src/browser/url_utils.cpp src/cef/browser_client.cpp src/cef/nebula_app.cpp + src/extensions/extension_manager.cpp src/ui/paths.cpp ) diff --git a/examples/extensions/hello-nebula/README.txt b/examples/extensions/hello-nebula/README.txt new file mode 100644 index 0000000..ff68662 --- /dev/null +++ b/examples/extensions/hello-nebula/README.txt @@ -0,0 +1,10 @@ +Copy this folder into Nebula's extensions directory to install: + + %LOCALAPPDATA%\Nebula\User Data\extensions\hello-nebula\ + +Then open Settings > Plugins and click Reload Plugins (or restart Nebula). + +manifest.json fields: + id, name, version, description, enabled + content_scripts[].matches (e.g. "*://*/*", "*://*.youtube.com/*", "") + content_scripts[].js / css (paths relative to this folder) diff --git a/examples/extensions/hello-nebula/content.css b/examples/extensions/hello-nebula/content.css new file mode 100644 index 0000000..a4c9b21 --- /dev/null +++ b/examples/extensions/hello-nebula/content.css @@ -0,0 +1,20 @@ +#nebula-hello-badge { + position: fixed; + right: 16px; + bottom: 16px; + z-index: 2147483646; + padding: 10px 14px; + border-radius: 10px; + background: #101820; + color: #e8f7ff; + font: 600 13px/1.3 Segoe UI, sans-serif; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.28); + opacity: 1; + transition: opacity 0.4s ease, transform 0.4s ease; + pointer-events: none; +} + +#nebula-hello-badge.nebula-hello-badge--hide { + opacity: 0; + transform: translateY(8px); +} diff --git a/examples/extensions/hello-nebula/content.js b/examples/extensions/hello-nebula/content.js new file mode 100644 index 0000000..698ec02 --- /dev/null +++ b/examples/extensions/hello-nebula/content.js @@ -0,0 +1,15 @@ +(function () { + if (document.getElementById('nebula-hello-badge')) { + return; + } + + const badge = document.createElement('div'); + badge.id = 'nebula-hello-badge'; + badge.textContent = 'Nebula extension loaded'; + badge.title = 'hello-nebula sample extension'; + document.documentElement.appendChild(badge); + + window.setTimeout(() => { + badge.classList.add('nebula-hello-badge--hide'); + }, 3500); +})(); diff --git a/examples/extensions/hello-nebula/manifest.json b/examples/extensions/hello-nebula/manifest.json new file mode 100644 index 0000000..cc39b70 --- /dev/null +++ b/examples/extensions/hello-nebula/manifest.json @@ -0,0 +1,15 @@ +{ + "id": "hello-nebula", + "name": "Hello Nebula", + "version": "1.0.0", + "description": "Sample Nebula extension. Shows a small badge on every page.", + "enabled": true, + "content_scripts": [ + { + "matches": ["*://*/*"], + "js": ["content.js"], + "css": ["content.css"], + "run_at": "document_end" + } + ] +} diff --git a/src/app/nebula_controller.cpp b/src/app/nebula_controller.cpp index 3f434d8..a5a0407 100644 --- a/src/app/nebula_controller.cpp +++ b/src/app/nebula_controller.cpp @@ -17,6 +17,7 @@ #include "include/wrapper/cef_helpers.h" #include "platform/browser_host.h" #include "platform/default_browser.h" +#include "platform/paths_platform.h" #include "platform/process.h" #include "ui/paths.h" @@ -210,6 +211,8 @@ void NebulaController::OnWindowCreated() { window_->SetFullscreen(true); } + extension_manager_.Reload(); + first_run_setup_active_ = !big_picture_mode_ && initial_url_.empty() && ShouldShowFirstRunSetup(); @@ -477,6 +480,46 @@ void NebulaController::OnChromeCommand(const std::string& command, const std::st if (auto* tab = tabs_.ActiveTab()) { SendBigPictureState(*tab); } + } else if (command == "extensions-reload") { + extension_manager_.Reload(); + if (auto* tab = tabs_.ActiveTab(); tab && tab->browser) { + InjectSettingsExtensions(tab->browser); + } + } else if (command == "extensions-set-enabled") { + // payload: {"id":"...","enabled":true|false} + std::string id; + const size_t id_key = payload.find("\"id\""); + if (id_key != std::string::npos) { + const size_t colon = payload.find(':', id_key); + const size_t quote1 = + colon == std::string::npos ? std::string::npos : payload.find('"', colon + 1); + const size_t quote2 = + quote1 == std::string::npos ? std::string::npos : payload.find('"', quote1 + 1); + if (quote1 != std::string::npos && quote2 != std::string::npos) { + id = payload.substr(quote1 + 1, quote2 - quote1 - 1); + } + } + + bool enabled = true; + const size_t enabled_key = payload.find("\"enabled\""); + if (enabled_key != std::string::npos) { + size_t pos = payload.find(':', enabled_key); + if (pos != std::string::npos) { + ++pos; + while (pos < payload.size() && + std::isspace(static_cast(payload[pos]))) { + ++pos; + } + enabled = payload.compare(pos, 4, "true") == 0; + } + } + + if (!id.empty()) { + extension_manager_.SetEnabled(id, enabled); + if (auto* tab = tabs_.ActiveTab(); tab && tab->browser) { + InjectSettingsExtensions(tab->browser); + } + } } else if (command == "bigpicture-mouse-move") { SendBigPictureMouseMove(payload); } else if (command == "bigpicture-click") { @@ -541,8 +584,10 @@ void NebulaController::OnContentLoadProgressChanged(CefRefPtr browse void NebulaController::OnContentLoadFinished(CefRefPtr browser, const std::string& url) { if (nebula::ui::ToInternalUrl(url).starts_with(nebula::ui::GetSettingsUrl())) { InjectSettingsHistory(browser); + InjectSettingsExtensions(browser); } InjectBigPictureCursor(browser); + extension_manager_.InjectMatching(browser, nebula::ui::ToInternalUrl(url)); } void NebulaController::OnContentFaviconChanged(CefRefPtr browser, const std::vector& urls) { @@ -1266,6 +1311,21 @@ void NebulaController::InjectSettingsHistory(CefRefPtr browser) { browser->GetMainFrame()->ExecuteJavaScript(script, nebula::ui::GetSettingsUrl(), 0); } +void NebulaController::InjectSettingsExtensions(CefRefPtr browser) { + if (!browser) { + return; + } + + const std::string list_json = extension_manager_.ListJson(); + const std::string path_json = + nebula::browser::JsonEscape(nebula::platform::PathToUtf8(nebula::ui::GetExtensionsDirectory())); + const std::string script = + "window.__nebulaExtensions = " + list_json + ";" + "window.__nebulaExtensionsPath = \"" + path_json + "\";" + "if (typeof loadPluginsUI === 'function') { loadPluginsUI(); }"; + browser->GetMainFrame()->ExecuteJavaScript(script, nebula::ui::GetSettingsUrl(), 0); +} + void NebulaController::InjectBigPictureCursor(CefRefPtr browser) { if (!big_picture_mode_ || !browser) { return; diff --git a/src/app/nebula_controller.h b/src/app/nebula_controller.h index 114f78d..8c856a4 100644 --- a/src/app/nebula_controller.h +++ b/src/app/nebula_controller.h @@ -8,6 +8,7 @@ #include "app/run.h" #include "browser/tab_manager.h" #include "cef/browser_client.h" +#include "extensions/extension_manager.h" #include "platform/types.h" #include "window/nebula_window.h" @@ -80,6 +81,7 @@ private: void SendBigPictureState(const nebula::browser::NebulaTab& tab); void RecordSiteHistory(const std::string& url); void InjectSettingsHistory(CefRefPtr browser); + void InjectSettingsExtensions(CefRefPtr browser); void InjectBigPictureCursor(CefRefPtr browser); void RemoveBigPictureCursor(CefRefPtr browser); void PersistSession() const; @@ -113,6 +115,7 @@ private: std::vector> closing_tab_browsers_; std::unordered_set insecure_warning_bypasses_; std::vector site_history_; + nebula::extensions::ExtensionManager extension_manager_; }; } // namespace nebula::app diff --git a/src/cef/browser_client.cpp b/src/cef/browser_client.cpp index 399a82a..1f51e52 100644 --- a/src/cef/browser_client.cpp +++ b/src/cef/browser_client.cpp @@ -115,7 +115,9 @@ bool NebulaBrowserClient::OnProcessMessageReceived(CefRefPtr browser command == "check-default-browser" || command == "set-default-browser" || command == "clear-site-history" || - command == "clear-search-history"); + command == "clear-search-history" || + command == "extensions-reload" || + command == "extensions-set-enabled"); const bool allowed_setup_command = IsSetupFrame(frame) && (command == "complete-first-run" || command == "check-default-browser" || diff --git a/src/extensions/extension.h b/src/extensions/extension.h new file mode 100644 index 0000000..9827e45 --- /dev/null +++ b/src/extensions/extension.h @@ -0,0 +1,30 @@ +#pragma once + +#include +#include +#include + +namespace nebula::extensions { + +struct ContentScript { + std::vector matches; + std::vector js_files; + std::vector css_files; + std::vector js_sources; + std::vector css_sources; + std::string run_at = "document_end"; +}; + +struct Extension { + std::string id; + std::string name; + std::string version; + std::string description; + std::filesystem::path directory; + bool enabled = true; + bool load_error = false; + std::string load_error_message; + std::vector content_scripts; +}; + +} // namespace nebula::extensions diff --git a/src/extensions/extension_manager.cpp b/src/extensions/extension_manager.cpp new file mode 100644 index 0000000..6b4437c --- /dev/null +++ b/src/extensions/extension_manager.cpp @@ -0,0 +1,584 @@ +#include "extensions/extension_manager.h" + +#include +#include +#include +#include +#include +#include +#include + +#include "browser/url_utils.h" +#include "platform/paths_platform.h" +#include "ui/paths.h" + +namespace nebula::extensions { +namespace { + +std::string ReadFile(const std::filesystem::path& path) { + std::ifstream input(path, std::ios::binary); + if (!input) { + return {}; + } + + std::ostringstream buffer; + buffer << input.rdbuf(); + return buffer.str(); +} + +std::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; +} + +std::string SanitizeJsId(std::string value) { + for (char& ch : value) { + if (!(std::isalnum(static_cast(ch)) || ch == '_' || ch == '-')) { + ch = '_'; + } + } + if (value.empty()) { + value = "extension"; + } + return value; +} + +std::optional ReadStringValue(const std::string& object, std::string_view key) { + const size_t key_pos = object.find(key); + if (key_pos == std::string::npos) { + return std::nullopt; + } + + size_t colon = object.find(':', key_pos + key.size()); + if (colon == std::string::npos) { + return std::nullopt; + } + + size_t quote = object.find('"', colon + 1); + if (quote == std::string::npos) { + return std::nullopt; + } + + std::string value; + for (size_t i = quote + 1; i < object.size(); ++i) { + const char ch = object[i]; + if (ch == '"') { + return value; + } + if (ch == '\\' && i + 1 < object.size()) { + value.push_back(object[++i]); + continue; + } + value.push_back(ch); + } + return std::nullopt; +} + +std::optional ReadBoolValue(const std::string& object, std::string_view key) { + const size_t key_pos = object.find(key); + if (key_pos == std::string::npos) { + return std::nullopt; + } + + size_t colon = object.find(':', key_pos + key.size()); + if (colon == std::string::npos) { + return std::nullopt; + } + + ++colon; + while (colon < object.size() && std::isspace(static_cast(object[colon]))) { + ++colon; + } + + if (object.compare(colon, 4, "true") == 0) { + return true; + } + if (object.compare(colon, 5, "false") == 0) { + return false; + } + return std::nullopt; +} + +std::vector ReadStringArray(const std::string& object, std::string_view key) { + std::vector values; + const size_t key_pos = object.find(key); + if (key_pos == std::string::npos) { + return values; + } + + size_t bracket = object.find('[', key_pos + key.size()); + if (bracket == std::string::npos) { + return values; + } + + for (size_t i = bracket + 1; i < object.size(); ++i) { + const char ch = object[i]; + if (ch == ']') { + break; + } + if (ch != '"') { + continue; + } + + std::string value; + for (++i; i < object.size(); ++i) { + const char inner = object[i]; + if (inner == '"') { + values.push_back(value); + break; + } + if (inner == '\\' && i + 1 < object.size()) { + value.push_back(object[++i]); + continue; + } + value.push_back(inner); + } + } + return values; +} + +std::vector SplitContentScriptObjects(const std::string& json) { + std::vector objects; + constexpr std::string_view key = "\"content_scripts\""; + const size_t key_pos = json.find(key); + if (key_pos == std::string::npos) { + return objects; + } + + size_t bracket = json.find('[', key_pos + key.size()); + if (bracket == std::string::npos) { + return objects; + } + + int depth = 0; + size_t object_start = std::string::npos; + for (size_t i = bracket + 1; i < json.size(); ++i) { + const char ch = json[i]; + if (ch == '{') { + if (depth == 0) { + object_start = i; + } + ++depth; + } else if (ch == '}') { + if (depth > 0) { + --depth; + if (depth == 0 && object_start != std::string::npos) { + objects.push_back(json.substr(object_start, i - object_start + 1)); + object_start = std::string::npos; + } + } + } else if (ch == ']' && depth == 0) { + break; + } + } + return objects; +} + +bool MatchSegment(const std::string& pattern, const std::string& value) { + size_t p = 0; + size_t v = 0; + size_t star_p = std::string::npos; + size_t star_v = std::string::npos; + + while (v < value.size()) { + if (p < pattern.size() && (pattern[p] == '?' || pattern[p] == value[v])) { + ++p; + ++v; + } else if (p < pattern.size() && pattern[p] == '*') { + star_p = p++; + star_v = v; + } else if (star_p != std::string::npos) { + p = star_p + 1; + v = ++star_v; + } else { + return false; + } + } + + while (p < pattern.size() && pattern[p] == '*') { + ++p; + } + return p == pattern.size(); +} + +bool MatchUrlPattern(const std::string& pattern, const std::string& url) { + if (pattern == "") { + return url.starts_with("http://") || url.starts_with("https://") || url.starts_with("file:"); + } + + const std::string lower_pattern = ToLowerAscii(pattern); + const std::string lower_url = ToLowerAscii(url); + return MatchSegment(lower_pattern, lower_url); +} + +bool UrlMatchesAny(const std::vector& patterns, const std::string& url) { + for (const auto& pattern : patterns) { + if (MatchUrlPattern(pattern, url)) { + return true; + } + } + return false; +} + +bool IsSafeRelativePath(const std::string& relative) { + if (relative.empty() || relative.find('\\') != std::string::npos) { + return false; + } + if (relative.front() == '/' || relative.find("..") != std::string::npos) { + return false; + } + return true; +} + +std::string JsStringLiteral(const std::string& value) { + return "\"" + nebula::browser::JsonEscape(value) + "\""; +} + +std::string BuildCssInjectScript(const std::string& guard_id, const std::string& css) { + return "(function(){" + "var id=" + + JsStringLiteral(guard_id) + + ";" + "if(document.getElementById(id))return;" + "var style=document.createElement('style');" + "style.id=id;" + "style.textContent=" + + JsStringLiteral(css) + + ";" + "(document.head||document.documentElement).appendChild(style);" + "})();"; +} + +std::string BuildJsInjectScript(const std::string& guard_id, const std::string& source) { + return "(function(){" + "var k=" + + JsStringLiteral(guard_id) + + ";" + "if(window[k])return;window[k]=true;" + "try{" + + source + + "\n}catch(e){console.error('[Nebula Extension]',e);}" + "})();"; +} + +void WriteInstallReadme(const std::filesystem::path& extensions_dir) { + const auto readme = extensions_dir / "INSTALL.txt"; + if (std::filesystem::exists(readme)) { + return; + } + + std::ofstream output(readme, std::ios::binary | std::ios::trunc); + if (!output) { + return; + } + + output << "Nebula Extensions\n" + << "=================\n\n" + << "Drop an unpacked extension folder here (or copy/paste it into this directory).\n" + << "Each folder must contain a manifest.json file.\n\n" + << "Example layout:\n" + << " extensions/\n" + << " my-extension/\n" + << " manifest.json\n" + << " content.js\n\n" + << "Then open Settings > Plugins and click Reload Plugins\n" + << "(or restart Nebula).\n\n" + << "See examples/extensions/hello-nebula in the Nebula source tree\n" + << "for a starter package.\n"; +} + +} // namespace + +void ExtensionManager::EnsureInstallReadme() const { + const auto dir = nebula::ui::GetExtensionsDirectory(); + if (!dir.empty()) { + WriteInstallReadme(dir); + } +} + +void ExtensionManager::LoadDisabledState() { + enabled_overrides_.clear(); + const auto path = nebula::ui::GetExtensionsStatePath(); + if (path.empty()) { + return; + } + + const std::string json = ReadFile(path); + if (json.empty()) { + return; + } + + constexpr std::string_view key = "\"disabled\""; + const size_t key_pos = json.find(key); + if (key_pos == std::string::npos) { + return; + } + + for (const auto& id : ReadStringArray(json, key)) { + enabled_overrides_[id] = false; + } +} + +void ExtensionManager::SaveDisabledState() const { + const auto path = nebula::ui::GetExtensionsStatePath(); + if (path.empty()) { + return; + } + + std::string json = "{\n \"disabled\": ["; + bool first = true; + for (const auto& extension : extensions_) { + if (extension.enabled) { + continue; + } + if (!first) { + json += ", "; + } + first = false; + json += "\"" + nebula::browser::JsonEscape(extension.id) + "\""; + } + json += "]\n}\n"; + + std::filesystem::path temp_path = path; + temp_path += ".tmp"; + { + std::ofstream output(temp_path, std::ios::binary | std::ios::trunc); + if (!output) { + return; + } + output << json; + } + + std::error_code ec; + std::filesystem::remove(path, ec); + ec.clear(); + std::filesystem::rename(temp_path, path, ec); +} + +bool ExtensionManager::LoadExtensionDirectory(const std::filesystem::path& directory) { + const auto manifest_path = directory / "manifest.json"; + if (!std::filesystem::is_regular_file(manifest_path)) { + return false; + } + + Extension extension; + extension.directory = directory; + extension.id = nebula::platform::PathToUtf8(directory.filename()); + + const std::string manifest = ReadFile(manifest_path); + if (manifest.empty()) { + extension.load_error = true; + extension.load_error_message = "Unable to read manifest.json"; + extension.name = extension.id; + extensions_.push_back(std::move(extension)); + return true; + } + + if (auto id = ReadStringValue(manifest, "\"id\""); id && !id->empty()) { + extension.id = *id; + } + extension.name = ReadStringValue(manifest, "\"name\"").value_or(extension.id); + extension.version = ReadStringValue(manifest, "\"version\"").value_or("0.0.0"); + extension.description = ReadStringValue(manifest, "\"description\"").value_or(""); + extension.enabled = ReadBoolValue(manifest, "\"enabled\"").value_or(true); + + if (const auto override = enabled_overrides_.find(extension.id); + override != enabled_overrides_.end()) { + extension.enabled = override->second; + } + + for (const auto& object : SplitContentScriptObjects(manifest)) { + ContentScript script; + script.matches = ReadStringArray(object, "\"matches\""); + script.js_files = ReadStringArray(object, "\"js\""); + script.css_files = ReadStringArray(object, "\"css\""); + script.run_at = ReadStringValue(object, "\"run_at\"").value_or("document_end"); + + if (script.matches.empty()) { + script.matches.push_back(""); + } + + bool ok = true; + for (const auto& relative : script.js_files) { + if (!IsSafeRelativePath(relative)) { + ok = false; + extension.load_error_message = "Unsafe js path: " + relative; + break; + } + const auto path = directory / relative; + const std::string source = ReadFile(path); + if (source.empty() && !std::filesystem::is_regular_file(path)) { + ok = false; + extension.load_error_message = "Missing js file: " + relative; + break; + } + script.js_sources.push_back(source); + } + + if (ok) { + for (const auto& relative : script.css_files) { + if (!IsSafeRelativePath(relative)) { + ok = false; + extension.load_error_message = "Unsafe css path: " + relative; + break; + } + const auto path = directory / relative; + const std::string source = ReadFile(path); + if (source.empty() && !std::filesystem::is_regular_file(path)) { + ok = false; + extension.load_error_message = "Missing css file: " + relative; + break; + } + script.css_sources.push_back(source); + } + } + + if (!ok) { + extension.load_error = true; + break; + } + + extension.content_scripts.push_back(std::move(script)); + } + + extensions_.push_back(std::move(extension)); + return true; +} + +void ExtensionManager::Reload() { + extensions_.clear(); + EnsureInstallReadme(); + LoadDisabledState(); + + const auto root = nebula::ui::GetExtensionsDirectory(); + if (root.empty() || !std::filesystem::is_directory(root)) { + return; + } + + std::error_code ec; + for (const auto& entry : std::filesystem::directory_iterator(root, ec)) { + if (!entry.is_directory(ec)) { + continue; + } + const auto name = nebula::platform::PathToUtf8(entry.path().filename()); + if (name.empty() || name.front() == '.') { + continue; + } + LoadExtensionDirectory(entry.path()); + } + + std::sort(extensions_.begin(), extensions_.end(), + [](const Extension& a, const Extension& b) { return a.name < b.name; }); +} + +bool ExtensionManager::SetEnabled(const std::string& id, bool enabled) { + bool found = false; + for (auto& extension : extensions_) { + if (extension.id != id) { + continue; + } + extension.enabled = enabled; + found = true; + break; + } + + if (!found) { + return false; + } + + if (enabled) { + enabled_overrides_.erase(id); + } else { + enabled_overrides_[id] = false; + } + SaveDisabledState(); + return true; +} + +std::string ExtensionManager::ListJson() const { + std::string json = "["; + for (size_t i = 0; i < extensions_.size(); ++i) { + const auto& extension = extensions_[i]; + if (i > 0) { + json += ","; + } + json += "{"; + json += "\"id\":\"" + nebula::browser::JsonEscape(extension.id) + "\","; + json += "\"name\":\"" + nebula::browser::JsonEscape(extension.name) + "\","; + json += "\"version\":\"" + nebula::browser::JsonEscape(extension.version) + "\","; + json += "\"description\":\"" + nebula::browser::JsonEscape(extension.description) + "\","; + json += "\"dir\":\"" + + nebula::browser::JsonEscape(nebula::platform::PathToUtf8(extension.directory)) + + "\","; + json += "\"enabled\":"; + json += extension.enabled && !extension.load_error ? "true" : "false"; + json += ",\"loadError\":"; + json += extension.load_error ? "true" : "false"; + json += ",\"loadErrorMessage\":\"" + + nebula::browser::JsonEscape(extension.load_error_message) + "\""; + json += ",\"categories\":[\"extension\"]"; + json += ",\"authors\":[]"; + json += "}"; + } + json += "]"; + return json; +} + +void ExtensionManager::InjectMatching(CefRefPtr browser, const std::string& url) const { + if (!browser || url.empty()) { + return; + } + + CefRefPtr frame = browser->GetMainFrame(); + if (!frame) { + return; + } + + if (nebula::ui::IsNebulaInternalUrl(url) || nebula::ui::IsChromiumNewTabUrl(url)) { + return; + } + + for (const auto& extension : extensions_) { + if (!extension.enabled || extension.load_error) { + continue; + } + + const std::string ext_guard = SanitizeJsId(extension.id); + size_t script_index = 0; + for (const auto& script : extension.content_scripts) { + if (!UrlMatchesAny(script.matches, url)) { + ++script_index; + continue; + } + + for (size_t i = 0; i < script.css_sources.size(); ++i) { + const std::string guard = + "nebula-ext-css-" + ext_guard + "-" + std::to_string(script_index) + "-" + + std::to_string(i); + frame->ExecuteJavaScript(BuildCssInjectScript(guard, script.css_sources[i]), url, 0); + } + + for (size_t i = 0; i < script.js_sources.size(); ++i) { + const std::string guard = + "__nebulaExt_" + ext_guard + "_" + std::to_string(script_index) + "_" + + std::to_string(i); + frame->ExecuteJavaScript(BuildJsInjectScript(guard, script.js_sources[i]), url, 0); + } + ++script_index; + } + } +} + +} // namespace nebula::extensions diff --git a/src/extensions/extension_manager.h b/src/extensions/extension_manager.h new file mode 100644 index 0000000..a4394ea --- /dev/null +++ b/src/extensions/extension_manager.h @@ -0,0 +1,32 @@ +#pragma once + +#include +#include +#include + +#include "extensions/extension.h" +#include "include/cef_browser.h" + +namespace nebula::extensions { + +// Loads unpacked Nebula extensions from GetExtensionsDirectory(). +// Install by copying an extension folder (with manifest.json) into that path. +class ExtensionManager { +public: + void Reload(); + bool SetEnabled(const std::string& id, bool enabled); + const std::vector& Extensions() const { return extensions_; } + std::string ListJson() const; + void InjectMatching(CefRefPtr browser, const std::string& url) const; + +private: + void EnsureInstallReadme() const; + void LoadDisabledState(); + void SaveDisabledState() const; + bool LoadExtensionDirectory(const std::filesystem::path& directory); + + std::vector extensions_; + std::unordered_map enabled_overrides_; +}; + +} // namespace nebula::extensions diff --git a/src/ui/paths.cpp b/src/ui/paths.cpp index 5ff6a24..5dfa007 100644 --- a/src/ui/paths.cpp +++ b/src/ui/paths.cpp @@ -155,6 +155,23 @@ std::filesystem::path GetCacheDirectory() { return cache; } +std::filesystem::path GetExtensionsDirectory() { + auto user_data = GetUserDataDirectory(); + if (user_data.empty()) { + return {}; + } + + std::filesystem::path extensions = user_data / "extensions"; + std::error_code ec; + std::filesystem::create_directories(extensions, ec); + return extensions; +} + +std::filesystem::path GetExtensionsStatePath() { + auto user_data = GetUserDataDirectory(); + return user_data.empty() ? std::filesystem::path{} : user_data / "extensions_state.json"; +} + std::filesystem::path GetSessionStatePath() { auto user_data = GetUserDataDirectory(); return user_data.empty() ? std::filesystem::path{} : user_data / "session_state.json"; diff --git a/src/ui/paths.h b/src/ui/paths.h index cb79a51..056aa63 100644 --- a/src/ui/paths.h +++ b/src/ui/paths.h @@ -14,6 +14,8 @@ const std::string& GetAppProfile(); std::filesystem::path GetExecutableDirectory(); std::filesystem::path GetUserDataDirectory(); std::filesystem::path GetCacheDirectory(); +std::filesystem::path GetExtensionsDirectory(); +std::filesystem::path GetExtensionsStatePath(); std::filesystem::path GetSessionStatePath(); std::filesystem::path GetFirstRunStatePath(); std::filesystem::path GetUiPagePath(const std::string& page_name); diff --git a/ui/js/settings.js b/ui/js/settings.js index f577c2b..abce66b 100644 --- a/ui/js/settings.js +++ b/ui/js/settings.js @@ -919,22 +919,42 @@ window.addEventListener('DOMContentLoaded', () => { // ----------------------------- // Plugins management (Settings) // ----------------------------- +function postExtensionCommand(command, payload) { + if (hasNebulaNativeBridge()) { + window.nebulaNative.postMessage(command, payload == null ? '' : String(payload)); + return true; + } + return false; +} + async function loadPluginsUI() { const listEl = document.getElementById('plugins-list'); const reloadAllBtn = document.getElementById('plugins-reload-all'); + const hintEl = document.getElementById('plugins-install-hint'); if (!listEl) return; - // Load list - let items = []; - try { - items = (ipc ? await ipc.invoke('plugins-list') : []) || []; - } catch (e) { - console.warn('plugins-list failed', e); + + if (hintEl && window.__nebulaExtensionsPath) { + hintEl.innerHTML = + 'Install extensions by copying an unpacked folder into:
' + + escapeHtml(window.__nebulaExtensionsPath) + + '
Each folder needs a manifest.json. Then click Reload Plugins.'; } + + let items = Array.isArray(window.__nebulaExtensions) ? window.__nebulaExtensions : null; + if (!items) { + try { + items = (ipc ? await ipc.invoke('plugins-list') : []) || []; + } catch (e) { + console.warn('plugins-list failed', e); + items = []; + } + } + listEl.innerHTML = ''; if (!items.length) { const empty = document.createElement('div'); empty.className = 'plugin-item'; - empty.textContent = 'No plugins found'; + empty.textContent = 'No plugins found. Drop an extension folder into the path above.'; listEl.appendChild(empty); } else { for (const p of items) { @@ -942,6 +962,9 @@ async function loadPluginsUI() { const authors = Array.isArray(p.authors) ? p.authors.filter(x => x && typeof x === 'string') : []; const tagsHtml = categories.length ? `
${categories.map(c => `${escapeHtml(c)}`).join('')}
` : ''; const authorsHtml = authors.length ? `
Authors: ${authors.map(a => `${escapeHtml(a)}`).join(', ')}
` : ''; + const errorHtml = p.loadError + ? `
Load error: ${escapeHtml(p.loadErrorMessage || 'unknown')}
` + : ''; const row = document.createElement('div'); row.className = 'plugin-item'; row.setAttribute('role', 'listitem'); @@ -951,11 +974,12 @@ async function loadPluginsUI() {
${escapeHtml(p.description || '')}
${tagsHtml} ${authorsHtml} + ${errorHtml}
${escapeHtml(p.dir)}
@@ -967,9 +991,16 @@ async function loadPluginsUI() { enableInput.addEventListener('change', async () => { const enabled = enableInput.checked; try { - if (ipc) await ipc.invoke('plugins-set-enabled', { id: p.id, enabled }); - labelSpan.textContent = enabled ? 'Enabled' : 'Disabled'; - showStatus(`${p.name}: ${enabled ? 'Enabled' : 'Disabled'}.`); + if (postExtensionCommand('extensions-set-enabled', JSON.stringify({ id: p.id, enabled }))) { + labelSpan.textContent = enabled ? 'Enabled' : 'Disabled'; + showStatus(`${p.name}: ${enabled ? 'Enabled' : 'Disabled'}.`); + } else if (ipc) { + await ipc.invoke('plugins-set-enabled', { id: p.id, enabled }); + labelSpan.textContent = enabled ? 'Enabled' : 'Disabled'; + showStatus(`${p.name}: ${enabled ? 'Enabled' : 'Disabled'}.`); + } else { + throw new Error('No extension bridge'); + } } catch (e) { console.error('Failed to toggle plugin', p.id, e); enableInput.checked = !enabled; @@ -980,8 +1011,14 @@ async function loadPluginsUI() { const reloadBtn = row.querySelector('button.plugin-reload'); reloadBtn.addEventListener('click', async () => { try { - if (ipc) await ipc.invoke('plugins-reload', { id: p.id }); - showStatus(`${p.name} reloaded.`); + if (postExtensionCommand('extensions-reload', JSON.stringify({ id: p.id }))) { + showStatus(`${p.name} reloaded.`); + } else if (ipc) { + await ipc.invoke('plugins-reload', { id: p.id }); + showStatus(`${p.name} reloaded.`); + } else { + throw new Error('No extension bridge'); + } } catch (e) { console.error('Plugin reload failed', e); showStatus('Reload failed'); @@ -991,10 +1028,23 @@ async function loadPluginsUI() { } } if (reloadAllBtn) reloadAllBtn.onclick = async () => { - try { if (ipc) await ipc.invoke('plugins-reload', {}); showStatus('Plugins reloaded.'); } catch { showStatus('Reload failed'); } + try { + if (postExtensionCommand('extensions-reload', '{}')) { + showStatus('Plugins reloaded.'); + } else if (ipc) { + await ipc.invoke('plugins-reload', {}); + showStatus('Plugins reloaded.'); + } else { + throw new Error('No extension bridge'); + } + } catch { + showStatus('Reload failed'); + } }; } +window.loadPluginsUI = loadPluginsUI; + function escapeHtml(s) { return String(s).replace(/[&<>"']/g, (c) => ({'&':'&','<':'<','>':'>','"':'"','\'':'''}[c])); } diff --git a/ui/pages/settings.html b/ui/pages/settings.html index a981610..0f83b45 100644 --- a/ui/pages/settings.html +++ b/ui/pages/settings.html @@ -250,9 +250,13 @@

Plugins

+

+ Install extensions by copying an unpacked folder into Nebula's extensions directory. + Each folder needs a manifest.json. Then click Reload Plugins. +

- Changes to renderer preloads may require app restart. + Content scripts apply on the next page load.