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.