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/cef/browser_client.cpp
src/cef/nebula_app.cpp
src/extensions/extension_manager.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 "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<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") {
SendBigPictureMouseMove(payload);
} 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) {
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<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);
}
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) {
if (!big_picture_mode_ || !browser) {
return;
+3
View File
@@ -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<CefBrowser> browser);
void InjectSettingsExtensions(CefRefPtr<CefBrowser> browser);
void InjectBigPictureCursor(CefRefPtr<CefBrowser> browser);
void RemoveBigPictureCursor(CefRefPtr<CefBrowser> browser);
void PersistSession() const;
@@ -113,6 +115,7 @@ private:
std::vector<CefRefPtr<CefBrowser>> closing_tab_browsers_;
std::unordered_set<std::string> insecure_warning_bypasses_;
std::vector<std::string> site_history_;
nebula::extensions::ExtensionManager extension_manager_;
};
} // namespace nebula::app
+3 -1
View File
@@ -115,7 +115,9 @@ bool NebulaBrowserClient::OnProcessMessageReceived(CefRefPtr<CefBrowser> 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" ||
+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;
}
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";
+2
View File
@@ -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);
+57 -7
View File
@@ -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 = [];
if (hintEl && window.__nebulaExtensionsPath) {
hintEl.innerHTML =
'Install extensions by copying an unpacked folder into:<br><code style="word-break:break-all;">' +
escapeHtml(window.__nebulaExtensionsPath) +
'</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 = '';
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 ? `<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 errorHtml = p.loadError
? `<div class="plugin-desc" style="color:#c62828;">Load error: ${escapeHtml(p.loadErrorMessage || 'unknown')}</div>`
: '';
const row = document.createElement('div');
row.className = 'plugin-item';
row.setAttribute('role', 'listitem');
@@ -951,11 +974,12 @@ async function loadPluginsUI() {
<div class="plugin-desc">${escapeHtml(p.description || '')}</div>
${tagsHtml}
${authorsHtml}
${errorHtml}
<div class="plugin-desc" style="opacity:.6; font-size:.85em;">${escapeHtml(p.dir)}</div>
</div>
<div class="plugin-actions">
<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>
</label>
<span class="spacer"></span>
@@ -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 });
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 });
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) => ({'&':'&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">
<h2>Plugins</h2>
<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">
<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 class="customization-group">