class KimiPluginManager {
constructor() {
this.plugins = [];
this.pluginsRoot = "kimi-plugins/";
}
// Common security validation for plugin file paths
isValidPluginPath(path) {
return (
typeof path === "string" &&
/^[-a-zA-Z0-9_\/.]+$/.test(path) &&
!path.startsWith("/") &&
!path.includes("..") &&
!/^https?:\/\//i.test(path) &&
path.startsWith("kimi-plugins/")
);
}
async loadPlugins() {
const pluginDirs = await this.getPluginDirs();
this.plugins = [];
let pluginThemeActive = false;
for (const dir of pluginDirs) {
try {
const manifest = await fetch(this.pluginsRoot + dir + "/manifest.json").then(r => r.json());
manifest._dir = dir;
manifest.enabled = this.isPluginEnabled(dir, manifest.enabled);
// Basic manifest validation and path sanitization (deny external or absolute URLs)
const validTypes = new Set(["theme", "voice", "behavior"]);
const isSafePath = p =>
typeof p === "string" &&
/^[-a-zA-Z0-9_\/.]+$/.test(p) &&
!p.startsWith("/") &&
!p.includes("..") &&
!/^https?:\/\//i.test(p);
if (!manifest.name || !manifest.type || !validTypes.has(manifest.type)) {
console.warn(`Invalid plugin manifest in ${dir}: missing name or invalid type`);
continue;
}
if (manifest.style && !isSafePath(manifest.style)) {
console.warn(`Blocked unsafe style path in ${dir}: ${manifest.style}`);
delete manifest.style;
}
if (manifest.main && !isSafePath(manifest.main)) {
console.warn(`Blocked unsafe main path in ${dir}: ${manifest.main}`);
delete manifest.main;
}
this.plugins.push(manifest);
if (manifest.enabled && manifest.style) {
this.loadCSS(this.pluginsRoot + dir + "/" + manifest.style);
}
if (manifest.enabled && manifest.main) {
this.loadJS(this.pluginsRoot + dir + "/" + manifest.main);
}
if (manifest.enabled && manifest.type === "theme" && dir === "sample-theme") {
pluginThemeActive = true;
}
} catch (e) {
console.warn("Failed loading plugin:", dir, e);
}
}
if (pluginThemeActive) {
document.documentElement.setAttribute("data-theme", "plugin-sample-theme");
} else {
// Restore previous or default theme depuis Dexie
if (window.kimiDB && window.kimiDB.getPreference) {
const userTheme = await window.kimiDB.getPreference("colorTheme", "purple");
document.documentElement.setAttribute("data-theme", userTheme);
} else {
document.documentElement.setAttribute("data-theme", "purple");
}
}
this.renderPluginList();
}
async getPluginDirs() {
return ["sample-theme", "sample-voice", "sample-behavior"];
}
loadCSS(href) {
if (!window.KimiDOMUtils) {
console.error("KimiDOMUtils not available for loadCSS");
return;
}
if (!window.KimiDOMUtils.get('link[href="' + href + '"]')) {
if (!this.isValidPluginPath(href)) {
console.error(`Blocked unsafe CSS path: ${href}`);
return;
}
const link = document.createElement("link");
link.rel = "stylesheet";
link.type = "text/css";
link.href = href;
link.onerror = function () {
console.error(`Failed to load plugin CSS: ${href}`);
};
document.head.appendChild(link);
}
}
loadJS(src) {
if (!window.KimiDOMUtils) {
console.error("KimiDOMUtils not available for loadJS");
return;
}
if (!window.KimiDOMUtils.get('script[src="' + src + '"]')) {
if (!this.isValidPluginPath(src)) {
console.error(`Blocked unsafe script path: ${src}`);
return;
}
const script = document.createElement("script");
script.src = src;
script.type = "text/javascript";
script.onerror = function () {
console.error(`Failed to load plugin script: ${src}`);
};
if (window.CSP_NONCE) {
script.nonce = window.CSP_NONCE;
}
document.body.appendChild(script);
}
}
renderPluginList() {
if (!window.KimiDOMUtils) {
console.error("KimiDOMUtils not available");
return;
}
const container = window.KimiDOMUtils.get("#plugin-list");
if (!container) return;
while (container.firstChild) {
container.removeChild(container.firstChild);
}
for (const plugin of this.plugins) {
const div = document.createElement("div");
div.className = "plugin-card";
// Left: info
const info = document.createElement("div");
info.className = "plugin-info";
const title = document.createElement("div");
title.className = "plugin-title";
title.textContent = plugin.name;
const type = document.createElement("span");
type.className = "plugin-type";
type.textContent = plugin.type;
title.appendChild(type);
const desc = document.createElement("div");
desc.className = "plugin-desc";
desc.textContent = plugin.description;
const author = document.createElement("div");
author.className = "plugin-author";
author.textContent = plugin.author;
info.appendChild(title);
info.appendChild(desc);
info.appendChild(author);
div.appendChild(info);
// Center: badges/swatch
const centerCol = document.createElement("div");
centerCol.className = "plugin-card-center";
const typeBadge = document.createElement("span");
typeBadge.className = "plugin-type-badge";
typeBadge.textContent =
plugin.type === "theme" ? "Theme" : plugin.type.charAt(0).toUpperCase() + plugin.type.slice(1);
centerCol.appendChild(typeBadge);
if (plugin.type === "theme") {
const swatch = document.createElement("div");
swatch.className = "plugin-theme-swatch";
// Create color spans safely
const colors = ["#3b82f6", "#a5b4fc", "#6366f1"];
colors.forEach(color => {
const span = document.createElement("span");
span.style.background = color;
swatch.appendChild(span);
});
centerCol.appendChild(swatch);
if (plugin.enabled) {
const activeBadge = document.createElement("span");
activeBadge.className = "plugin-active-badge";
activeBadge.textContent = "Active Theme";
centerCol.appendChild(activeBadge);
}
}
div.appendChild(centerCol);
// Right: switch
const rightCol = document.createElement("div");
rightCol.className = "plugin-card-switch";
const switchLabel = document.createElement("label");
switchLabel.className = "toggle-switch";
const input = document.createElement("input");
input.type = "checkbox";
input.checked = !!plugin.enabled;
input.style.display = "none";
input.addEventListener("change", () => {
plugin.enabled = input.checked;
this.savePluginState(plugin._dir, plugin.enabled);
this.loadPlugins();
if (input.checked) {
switchLabel.classList.add("active");
} else {
switchLabel.classList.remove("active");
}
});
const slider = document.createElement("span");
slider.className = "slider";
switchLabel.appendChild(input);
switchLabel.appendChild(slider);
if (input.checked) switchLabel.classList.add("active");
rightCol.appendChild(switchLabel);
div.appendChild(rightCol);
container.appendChild(div);
}
}
savePluginState(dir, enabled) {
const key = "kimi-plugin-enabled-" + dir;
localStorage.setItem(key, enabled ? "1" : "0");
}
isPluginEnabled(dir, defaultValue) {
const key = "kimi-plugin-enabled-" + dir;
const val = localStorage.getItem(key);
if (val === null) return defaultValue;
return val === "1";
}
}
window.KimiPluginManager = new KimiPluginManager();
document.addEventListener("DOMContentLoaded", () => {
if (window.KimiPluginManager) window.KimiPluginManager.loadPlugins();
const refreshBtn = document.getElementById("refresh-plugins");
if (refreshBtn) {
refreshBtn.onclick = async () => {
const originalText = refreshBtn.innerHTML;
refreshBtn.innerHTML = ' Refreshing...';
refreshBtn.disabled = true;
try {
await window.KimiPluginManager.loadPlugins();
refreshBtn.innerHTML = ' Refreshed!';
setTimeout(() => {
refreshBtn.innerHTML = originalText;
refreshBtn.disabled = false;
}, 1500);
} catch (error) {
console.error("Error refreshing plugins:", error);
refreshBtn.innerHTML = ' Error';
setTimeout(() => {
refreshBtn.innerHTML = originalText;
refreshBtn.disabled = false;
}, 2000);
}
};
}
});