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.
This commit is contained in:
Andrew Zambazos
2026-07-12 20:21:32 +12:00
parent 487c5fc1ae
commit 924529807f
15 changed files with 861 additions and 16 deletions
+1
View File
@@ -49,6 +49,7 @@ set(NEBULA_COMMON_SOURCES
src/browser/url_utils.cpp src/browser/url_utils.cpp
src/cef/browser_client.cpp src/cef/browser_client.cpp
src/cef/nebula_app.cpp src/cef/nebula_app.cpp
src/extensions/extension_manager.cpp
src/ui/paths.cpp src/ui/paths.cpp
) )
@@ -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/*", "<all_urls>")
content_scripts[].js / css (paths relative to this folder)
@@ -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);
}
@@ -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);
})();
@@ -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"
}
]
}
+60
View File
@@ -17,6 +17,7 @@
#include "include/wrapper/cef_helpers.h" #include "include/wrapper/cef_helpers.h"
#include "platform/browser_host.h" #include "platform/browser_host.h"
#include "platform/default_browser.h" #include "platform/default_browser.h"
#include "platform/paths_platform.h"
#include "platform/process.h" #include "platform/process.h"
#include "ui/paths.h" #include "ui/paths.h"
@@ -210,6 +211,8 @@ void NebulaController::OnWindowCreated() {
window_->SetFullscreen(true); window_->SetFullscreen(true);
} }
extension_manager_.Reload();
first_run_setup_active_ = first_run_setup_active_ =
!big_picture_mode_ && initial_url_.empty() && ShouldShowFirstRunSetup(); !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()) { if (auto* tab = tabs_.ActiveTab()) {
SendBigPictureState(*tab); 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<unsigned char>(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") { } else if (command == "bigpicture-mouse-move") {
SendBigPictureMouseMove(payload); SendBigPictureMouseMove(payload);
} else if (command == "bigpicture-click") { } else if (command == "bigpicture-click") {
@@ -541,8 +584,10 @@ void NebulaController::OnContentLoadProgressChanged(CefRefPtr<CefBrowser> browse
void NebulaController::OnContentLoadFinished(CefRefPtr<CefBrowser> browser, const std::string& url) { void NebulaController::OnContentLoadFinished(CefRefPtr<CefBrowser> browser, const std::string& url) {
if (nebula::ui::ToInternalUrl(url).starts_with(nebula::ui::GetSettingsUrl())) { if (nebula::ui::ToInternalUrl(url).starts_with(nebula::ui::GetSettingsUrl())) {
InjectSettingsHistory(browser); InjectSettingsHistory(browser);
InjectSettingsExtensions(browser);
} }
InjectBigPictureCursor(browser); InjectBigPictureCursor(browser);
extension_manager_.InjectMatching(browser, nebula::ui::ToInternalUrl(url));
} }
void NebulaController::OnContentFaviconChanged(CefRefPtr<CefBrowser> browser, const std::vector<std::string>& urls) { void NebulaController::OnContentFaviconChanged(CefRefPtr<CefBrowser> browser, const std::vector<std::string>& urls) {
@@ -1266,6 +1311,21 @@ void NebulaController::InjectSettingsHistory(CefRefPtr<CefBrowser> browser) {
browser->GetMainFrame()->ExecuteJavaScript(script, nebula::ui::GetSettingsUrl(), 0); browser->GetMainFrame()->ExecuteJavaScript(script, nebula::ui::GetSettingsUrl(), 0);
} }
void NebulaController::InjectSettingsExtensions(CefRefPtr<CefBrowser> 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<CefBrowser> browser) { void NebulaController::InjectBigPictureCursor(CefRefPtr<CefBrowser> browser) {
if (!big_picture_mode_ || !browser) { if (!big_picture_mode_ || !browser) {
return; return;
+3
View File
@@ -8,6 +8,7 @@
#include "app/run.h" #include "app/run.h"
#include "browser/tab_manager.h" #include "browser/tab_manager.h"
#include "cef/browser_client.h" #include "cef/browser_client.h"
#include "extensions/extension_manager.h"
#include "platform/types.h" #include "platform/types.h"
#include "window/nebula_window.h" #include "window/nebula_window.h"
@@ -80,6 +81,7 @@ private:
void SendBigPictureState(const nebula::browser::NebulaTab& tab); void SendBigPictureState(const nebula::browser::NebulaTab& tab);
void RecordSiteHistory(const std::string& url); void RecordSiteHistory(const std::string& url);
void InjectSettingsHistory(CefRefPtr<CefBrowser> browser); void InjectSettingsHistory(CefRefPtr<CefBrowser> browser);
void InjectSettingsExtensions(CefRefPtr<CefBrowser> browser);
void InjectBigPictureCursor(CefRefPtr<CefBrowser> browser); void InjectBigPictureCursor(CefRefPtr<CefBrowser> browser);
void RemoveBigPictureCursor(CefRefPtr<CefBrowser> browser); void RemoveBigPictureCursor(CefRefPtr<CefBrowser> browser);
void PersistSession() const; void PersistSession() const;
@@ -113,6 +115,7 @@ private:
std::vector<CefRefPtr<CefBrowser>> closing_tab_browsers_; std::vector<CefRefPtr<CefBrowser>> closing_tab_browsers_;
std::unordered_set<std::string> insecure_warning_bypasses_; std::unordered_set<std::string> insecure_warning_bypasses_;
std::vector<std::string> site_history_; std::vector<std::string> site_history_;
nebula::extensions::ExtensionManager extension_manager_;
}; };
} // namespace nebula::app } // namespace nebula::app
+3 -1
View File
@@ -115,7 +115,9 @@ bool NebulaBrowserClient::OnProcessMessageReceived(CefRefPtr<CefBrowser> browser
command == "check-default-browser" || command == "check-default-browser" ||
command == "set-default-browser" || command == "set-default-browser" ||
command == "clear-site-history" || command == "clear-site-history" ||
command == "clear-search-history"); command == "clear-search-history" ||
command == "extensions-reload" ||
command == "extensions-set-enabled");
const bool allowed_setup_command = const bool allowed_setup_command =
IsSetupFrame(frame) && (command == "complete-first-run" || IsSetupFrame(frame) && (command == "complete-first-run" ||
command == "check-default-browser" || command == "check-default-browser" ||
+30
View File
@@ -0,0 +1,30 @@
#pragma once
#include <filesystem>
#include <string>
#include <vector>
namespace nebula::extensions {
struct ContentScript {
std::vector<std::string> matches;
std::vector<std::string> js_files;
std::vector<std::string> css_files;
std::vector<std::string> js_sources;
std::vector<std::string> 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<ContentScript> content_scripts;
};
} // namespace nebula::extensions
+584
View File
@@ -0,0 +1,584 @@
#include "extensions/extension_manager.h"
#include <algorithm>
#include <cctype>
#include <fstream>
#include <optional>
#include <sstream>
#include <string_view>
#include <system_error>
#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<unsigned char>(value.front()))) {
value.erase(value.begin());
}
while (!value.empty() && std::isspace(static_cast<unsigned char>(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<char>(std::tolower(ch));
});
return value;
}
std::string SanitizeJsId(std::string value) {
for (char& ch : value) {
if (!(std::isalnum(static_cast<unsigned char>(ch)) || ch == '_' || ch == '-')) {
ch = '_';
}
}
if (value.empty()) {
value = "extension";
}
return value;
}
std::optional<std::string> 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<bool> 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<unsigned char>(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<std::string> ReadStringArray(const std::string& object, std::string_view key) {
std::vector<std::string> 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<std::string> SplitContentScriptObjects(const std::string& json) {
std::vector<std::string> 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 == "<all_urls>") {
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<std::string>& 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("<all_urls>");
}
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<CefBrowser> browser, const std::string& url) const {
if (!browser || url.empty()) {
return;
}
CefRefPtr<CefFrame> 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
+32
View File
@@ -0,0 +1,32 @@
#pragma once
#include <string>
#include <unordered_map>
#include <vector>
#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<Extension>& Extensions() const { return extensions_; }
std::string ListJson() const;
void InjectMatching(CefRefPtr<CefBrowser> browser, const std::string& url) const;
private:
void EnsureInstallReadme() const;
void LoadDisabledState();
void SaveDisabledState() const;
bool LoadExtensionDirectory(const std::filesystem::path& directory);
std::vector<Extension> extensions_;
std::unordered_map<std::string, bool> enabled_overrides_;
};
} // namespace nebula::extensions
+17
View File
@@ -155,6 +155,23 @@ std::filesystem::path GetCacheDirectory() {
return cache; 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() { std::filesystem::path GetSessionStatePath() {
auto user_data = GetUserDataDirectory(); auto user_data = GetUserDataDirectory();
return user_data.empty() ? std::filesystem::path{} : user_data / "session_state.json"; return user_data.empty() ? std::filesystem::path{} : user_data / "session_state.json";
+2
View File
@@ -14,6 +14,8 @@ const std::string& GetAppProfile();
std::filesystem::path GetExecutableDirectory(); std::filesystem::path GetExecutableDirectory();
std::filesystem::path GetUserDataDirectory(); std::filesystem::path GetUserDataDirectory();
std::filesystem::path GetCacheDirectory(); std::filesystem::path GetCacheDirectory();
std::filesystem::path GetExtensionsDirectory();
std::filesystem::path GetExtensionsStatePath();
std::filesystem::path GetSessionStatePath(); std::filesystem::path GetSessionStatePath();
std::filesystem::path GetFirstRunStatePath(); std::filesystem::path GetFirstRunStatePath();
std::filesystem::path GetUiPagePath(const std::string& page_name); std::filesystem::path GetUiPagePath(const std::string& page_name);
+64 -14
View File
@@ -919,22 +919,42 @@ window.addEventListener('DOMContentLoaded', () => {
// ----------------------------- // -----------------------------
// Plugins management (Settings) // Plugins management (Settings)
// ----------------------------- // -----------------------------
function postExtensionCommand(command, payload) {
if (hasNebulaNativeBridge()) {
window.nebulaNative.postMessage(command, payload == null ? '' : String(payload));
return true;
}
return false;
}
async function loadPluginsUI() { async function loadPluginsUI() {
const listEl = document.getElementById('plugins-list'); const listEl = document.getElementById('plugins-list');
const reloadAllBtn = document.getElementById('plugins-reload-all'); const reloadAllBtn = document.getElementById('plugins-reload-all');
const hintEl = document.getElementById('plugins-install-hint');
if (!listEl) return; if (!listEl) return;
// Load list
let items = []; if (hintEl && window.__nebulaExtensionsPath) {
try { hintEl.innerHTML =
items = (ipc ? await ipc.invoke('plugins-list') : []) || []; 'Install extensions by copying an unpacked folder into:<br><code style="word-break:break-all;">' +
} catch (e) { escapeHtml(window.__nebulaExtensionsPath) +
console.warn('plugins-list failed', e); '</code><br>Each folder needs a <code>manifest.json</code>. 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 = ''; listEl.innerHTML = '';
if (!items.length) { if (!items.length) {
const empty = document.createElement('div'); const empty = document.createElement('div');
empty.className = 'plugin-item'; 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); listEl.appendChild(empty);
} else { } else {
for (const p of items) { 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 authors = Array.isArray(p.authors) ? p.authors.filter(x => x && typeof x === 'string') : [];
const tagsHtml = categories.length ? `<div class="plugin-tags">${categories.map(c => `<span class=\"plugin-tag\">${escapeHtml(c)}</span>`).join('')}</div>` : ''; const tagsHtml = categories.length ? `<div class="plugin-tags">${categories.map(c => `<span class=\"plugin-tag\">${escapeHtml(c)}</span>`).join('')}</div>` : '';
const authorsHtml = authors.length ? `<div class=\"plugin-authors\"><span class=\"muted\">Authors:</span> ${authors.map(a => `<span class=\"plugin-author\">${escapeHtml(a)}</span>`).join(', ')}</div>` : ''; const authorsHtml = authors.length ? `<div class=\"plugin-authors\"><span class=\"muted\">Authors:</span> ${authors.map(a => `<span class=\"plugin-author\">${escapeHtml(a)}</span>`).join(', ')}</div>` : '';
const errorHtml = p.loadError
? `<div class="plugin-desc" style="color:#c62828;">Load error: ${escapeHtml(p.loadErrorMessage || 'unknown')}</div>`
: '';
const row = document.createElement('div'); const row = document.createElement('div');
row.className = 'plugin-item'; row.className = 'plugin-item';
row.setAttribute('role', 'listitem'); row.setAttribute('role', 'listitem');
@@ -951,11 +974,12 @@ async function loadPluginsUI() {
<div class="plugin-desc">${escapeHtml(p.description || '')}</div> <div class="plugin-desc">${escapeHtml(p.description || '')}</div>
${tagsHtml} ${tagsHtml}
${authorsHtml} ${authorsHtml}
${errorHtml}
<div class="plugin-desc" style="opacity:.6; font-size:.85em;">${escapeHtml(p.dir)}</div> <div class="plugin-desc" style="opacity:.6; font-size:.85em;">${escapeHtml(p.dir)}</div>
</div> </div>
<div class="plugin-actions"> <div class="plugin-actions">
<label style="display:flex; align-items:center; gap:6px;"> <label style="display:flex; align-items:center; gap:6px;">
<input type="checkbox" class="plugin-enable" ${p.enabled ? 'checked' : ''}> <input type="checkbox" class="plugin-enable" ${p.enabled ? 'checked' : ''} ${p.loadError ? 'disabled' : ''}>
<span>${p.enabled ? 'Enabled' : 'Disabled'}</span> <span>${p.enabled ? 'Enabled' : 'Disabled'}</span>
</label> </label>
<span class="spacer"></span> <span class="spacer"></span>
@@ -967,9 +991,16 @@ async function loadPluginsUI() {
enableInput.addEventListener('change', async () => { enableInput.addEventListener('change', async () => {
const enabled = enableInput.checked; const enabled = enableInput.checked;
try { try {
if (ipc) await ipc.invoke('plugins-set-enabled', { id: p.id, enabled }); if (postExtensionCommand('extensions-set-enabled', JSON.stringify({ id: p.id, enabled }))) {
labelSpan.textContent = enabled ? 'Enabled' : 'Disabled'; labelSpan.textContent = enabled ? 'Enabled' : 'Disabled';
showStatus(`${p.name}: ${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) { } catch (e) {
console.error('Failed to toggle plugin', p.id, e); console.error('Failed to toggle plugin', p.id, e);
enableInput.checked = !enabled; enableInput.checked = !enabled;
@@ -980,8 +1011,14 @@ async function loadPluginsUI() {
const reloadBtn = row.querySelector('button.plugin-reload'); const reloadBtn = row.querySelector('button.plugin-reload');
reloadBtn.addEventListener('click', async () => { reloadBtn.addEventListener('click', async () => {
try { try {
if (ipc) await ipc.invoke('plugins-reload', { id: p.id }); if (postExtensionCommand('extensions-reload', JSON.stringify({ id: p.id }))) {
showStatus(`${p.name} reloaded.`); 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) { } catch (e) {
console.error('Plugin reload failed', e); console.error('Plugin reload failed', e);
showStatus('Reload failed'); showStatus('Reload failed');
@@ -991,10 +1028,23 @@ async function loadPluginsUI() {
} }
} }
if (reloadAllBtn) reloadAllBtn.onclick = async () => { 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) { function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, (c) => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;','\'':'&#39;'}[c])); return String(s).replace(/[&<>"']/g, (c) => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;','\'':'&#39;'}[c]));
} }
+5 -1
View File
@@ -250,9 +250,13 @@
<section class="tab-panel" id="panel-plugins" role="tabpanel" aria-labelledby="tab-plugins"> <section class="tab-panel" id="panel-plugins" role="tabpanel" aria-labelledby="tab-plugins">
<h2>Plugins</h2> <h2>Plugins</h2>
<div class="customization-group"> <div class="customization-group">
<p class="note" id="plugins-install-hint">
Install extensions by copying an unpacked folder into Nebula's extensions directory.
Each folder needs a <code>manifest.json</code>. Then click Reload Plugins.
</p>
<div class="button-row"> <div class="button-row">
<button id="plugins-reload-all">Reload Plugins</button> <button id="plugins-reload-all">Reload Plugins</button>
<span class="note">Changes to renderer preloads may require app restart.</span> <span class="note">Content scripts apply on the next page load.</span>
</div> </div>
</div> </div>
<div class="customization-group"> <div class="customization-group">