feat: add plugin marketplace and GitHub distribution

This commit is contained in:
tianma-if committed 2026-08-16 14:52:06 +08:00
1 parent 60e3d5f309
commit e9dfb3a76a
39 files changed
+3599 -9

No files matched your search

+4
View File
@@ -197,6 +197,10 @@ https://your-domain/api/openapi.json
Repository file: [docs/openapi.json](docs/openapi.json).
## Plugin Development Preview
The standalone Plugin Marketplace supports trusted client plugins and code-free theme packages installed from its verified index, a public GitHub repository, or a manifest URL. Plugins can query and update notes, work with editor selections, register commands and custom panels, use encrypted secret storage, and make allowlisted network requests. A unified top-right desktop entry includes recently used actions. Cron and background jobs are intentionally deferred. See [Plugin Development](docs/plugin-development.md).
## MCP
Create an API token in **Profile** -> **MCP settings**, then give the token or full MCP configuration to your AI Agent. Once connected, the Agent can securely read, organize, and import notes within your account permissions. MCP also exposes full management of reusable note templates and AI instructions: agents can list, inspect, create, update, and delete both, create a note from a template, and restore missing built-in AI instructions. Template and instruction reads use the `read:memos` scope, while changes use `write:memos`. Repeating the same memo import will not create duplicate notes.
+4
View File
@@ -197,6 +197,10 @@ https://你的域名/api/openapi.json
仓库内文件:[docs/openapi.json](docs/openapi.json)。
## 插件开发预览
独立的插件市场页面支持从已验证索引、公开 GitHub 仓库或 Manifest 地址安装受信任的客户端插件和无代码主题包。插件可以查询与修改笔记、操作编辑器选区、注册命令和自定义面板、使用加密 Secret Storage,并向声明过的域名发起网络请求。桌面端右上角提供统一插件入口和最近使用记录;Cron 与后台任务暂不纳入。详见[插件开发文档](docs/plugin-development.zh-CN.md)。
## MCP
先在 EdgeEver 左下角 **个人中心** 的 **MCP 设置** 中创建 API Token,再将 Token 或完整 MCP 配置发送给 AI Agent。连接后,Agent 即可在你的授权范围内安全地读取、整理和导入笔记。MCP 也开放了完整的笔记模板与 AI 指令管理能力:Agent 可以列出、查看、新建、更新和删除模板与指令,也可以使用模板创建笔记、恢复缺失的内置指令。读取模板和指令需要 `read:memos` 权限,变更需要 `write:memos` 权限;重复执行同一笔记导入任务不会创建重复笔记。
@@ -0,0 +1,38 @@
{
"type": "theme",
"id": "org.edgeever.themes.nord-emerald",
"name": "Nord Emerald",
"version": "1.0.0",
"themeApiVersion": "1",
"description": "A calm Nord-inspired EdgeEver theme using the public theme token API.",
"author": "EdgeEver",
"modes": ["light", "dark"],
"light": {
"color.background": "#eceff4",
"color.surface": "#ffffff",
"color.surfaceMuted": "#e5e9f0",
"color.text": "#2e3440",
"color.textMuted": "#5e6a7d",
"color.border": "#d8dee9",
"color.accent": "#168f6a",
"color.accentForeground": "#ffffff",
"font.body": "Inter, ui-sans-serif, system-ui, sans-serif",
"font.mono": "ui-monospace, SFMono-Regular, Menlo, monospace",
"radius.medium": "0.625rem",
"editor.contentWidth": "840px"
},
"dark": {
"color.background": "#242933",
"color.surface": "#2e3440",
"color.surfaceMuted": "#3b4252",
"color.text": "#eceff4",
"color.textMuted": "#b7c0d0",
"color.border": "#4c566a",
"color.accent": "#5ec49a",
"color.accentForeground": "#17211c",
"font.body": "Inter, ui-sans-serif, system-ui, sans-serif",
"font.mono": "ui-monospace, SFMono-Regular, Menlo, monospace",
"radius.medium": "0.625rem",
"editor.contentWidth": "840px"
}
}
@@ -0,0 +1,47 @@
export default {
activate(context) {
const disposeCount = context.commands.register({
id: "count-recent-notes",
title: "Count recent notes",
async run() {
const result = await context.notes.query({ sort: "updated-desc", limit: 10 });
context.ui.showNotice(`EdgeEver returned ${result.notes.length} recent notes.`);
},
});
const disposeUppercase = context.commands.register({
id: "uppercase-selection",
title: "Uppercase selection",
async run() {
const selection = await context.editor.getSelection();
if (!selection || selection.empty) {
context.ui.showNotice("Select some text in the active note first.");
return;
}
await context.editor.replaceSelection(selection.text.toUpperCase());
},
});
const disposePanel = context.ui.panels.register({
id: "recent-notes",
title: "Recent notes",
mount(container) {
const heading = document.createElement("h2");
heading.textContent = "Recent notes inspector";
heading.style.fontWeight = "700";
const output = document.createElement("p");
output.textContent = "Loading…";
output.style.marginTop = "12px";
container.append(heading, output);
void context.notes.query({ sort: "updated-desc", limit: 5 }).then((result) => {
output.textContent = result.notes.length
? result.notes.map((note) => note.title || note.excerpt || "Untitled").join(" · ")
: "No notes found.";
});
},
});
return () => {
disposeCount();
disposeUppercase();
disposePanel();
};
},
};
@@ -0,0 +1,12 @@
{
"type": "plugin",
"id": "org.edgeever.examples.recent-notes",
"name": "Recent Notes Example",
"version": "1.1.0",
"apiVersion": "1",
"description": "Demonstrates note queries, editor selections, commands, notices, and a custom panel.",
"author": "EdgeEver",
"entry": "./main.js",
"platforms": ["web", "desktop"],
"permissions": ["notes:read", "editor:read", "editor:write", "ui:commands", "ui:notices", "ui:panels"]
}
+43
View File
@@ -0,0 +1,43 @@
{
"registryVersion": "1",
"updatedAt": "2026-08-16T00:00:00.000Z",
"entries": [
{
"id": "org.edgeever.examples.recent-notes",
"name": "Recent Notes Example",
"description": "Demonstrates note queries, editor selections, commands, notices, and a custom panel.",
"author": "EdgeEver",
"category": "Productivity",
"repositoryUrl": "https://github.com/tianma-if/edgeever",
"distribution": {
"type": "manifest",
"manifestUrl": "/extensions/recent-notes/manifest.json"
},
"verification": {
"version": "1.1.0",
"checksums": {
"manifestJson": "641f12a272b1beec7e86daae422788c57562a1416724176e1941f4c5145ec3b8",
"mainJs": "b73f7c868376914423dd67b11b76938bd1da3353409c19cc53d6c41a43926e50"
}
}
},
{
"id": "org.edgeever.themes.nord-emerald",
"name": "Nord Emerald",
"description": "A calm Nord-inspired EdgeEver theme using the public theme token API.",
"author": "EdgeEver",
"category": "Themes",
"repositoryUrl": "https://github.com/tianma-if/edgeever",
"distribution": {
"type": "manifest",
"manifestUrl": "/extensions/nord-emerald/manifest.json"
},
"verification": {
"version": "1.0.0",
"checksums": {
"manifestJson": "18db9fc441c291ad2774d5a6b3a1929832b382f62d0e5116a45e27bfd5b65f4a"
}
}
}
]
}
+1
View File
@@ -227,6 +227,7 @@ export const App = () => {
<Route path={EVERNOTE_MIGRATION_PATH} element={<EvernoteMigrationRoute />} />
<Route path="/" element={<AuthenticatedWorkspace />} />
<Route path="/settings" element={<AuthenticatedWorkspace />} />
<Route path="/plugins" element={<AuthenticatedWorkspace />} />
<Route path="/templates" element={<AuthenticatedWorkspace />} />
<Route path="/ai-prompts" element={<AuthenticatedWorkspace />} />
<Route path="*" element={<Navigate to="/" replace />} />
+52
View File
@@ -177,6 +177,8 @@ import {
} from "./editor/note-search";
import { useEditorSaveStatus } from "./editor/useEditorSaveStatus";
import { resolveEditorDraftState } from "./editor/editor-draft-state";
import type { EdgeEverPluginHost, PluginEditorAdapter } from "@/lib/plugins/plugin-host";
import { PluginToolbarMenu } from "@/components/plugins/PluginToolbarMenu";
import {
useEditorResourceActions,
type AttachmentMenuTarget,
@@ -210,6 +212,16 @@ const IconTooltip = ({ label, children }: { label: string; children: ReactNode }
</TooltipProvider>
);
const EmptyEditorHeader = ({ pluginHost, onOpenPluginManager }: {
pluginHost: EdgeEverPluginHost;
onOpenPluginManager: () => void;
}) => (
<header className="hidden h-12 shrink-0 items-center justify-end gap-1 border-b border-slate-100 px-5 lg:flex">
<PluginToolbarMenu host={pluginHost} onManage={onOpenPluginManager} />
<ThemeToggle />
</header>
);
type NoteLinkHintPosition = {
left: number;
top: number;
@@ -615,6 +627,8 @@ type EditorPaneProps = {
selectionActionBar?: ReactNode;
onOpenMemo?: (memoId: string) => void;
onOpenAiPrompts?: () => void;
pluginHost: EdgeEverPluginHost;
onOpenPluginManager: () => void;
};
type RichEditorPaneProps = EditorPaneProps & {
@@ -679,6 +693,8 @@ const RichEditorPane = ({
selectionActionBar,
onOpenMemo,
onOpenAiPrompts,
pluginHost,
onOpenPluginManager,
onRequestMobileNativeEdit,
}: RichEditorPaneProps) => {
const { t, i18n } = useTranslation();
@@ -2675,6 +2691,39 @@ const RichEditorPane = ({
setSaveState("error");
},
});
const pluginEditorMemoId = memo?.id ?? null;
useEffect(() => {
if (!editor || !pluginEditorMemoId || effectiveReadOnly || hydratedEditorMemoId !== pluginEditorMemoId) {
return;
}
const adapter: PluginEditorAdapter = {
getSelection: () => {
const { selection, doc } = editor.state;
const context = getRichTextAiSelectionContext(doc, selection);
return {
noteId: pluginEditorMemoId,
from: selection.from,
to: selection.to,
empty: selection.empty,
text: doc.textBetween(selection.from, selection.to, "\n"),
contentMarkdown: context?.contentMarkdown ?? "",
};
},
replaceSelection: (contentMarkdown) => {
const { selection, doc } = editor.state;
const context = getRichTextAiSelectionContext(doc, selection);
const content = getRichTextAiSelectionReplacement(contentMarkdown, context?.isInline ?? true);
editor.chain().focus().insertContentAt({ from: selection.from, to: selection.to }, content).run();
},
insertAtCursor: (contentMarkdown) => {
const content = getRichTextAiSelectionReplacement(contentMarkdown, true);
editor.chain().focus().insertContent(content).run();
},
};
return pluginHost.setEditorAdapter(adapter);
}, [editor, effectiveReadOnly, hydratedEditorMemoId, pluginEditorMemoId, pluginHost]);
// useMutation returns a new result object on every render. Depending on the
// whole object makes autosave timers restart during unrelated renders and
// can starve a recovered draft indefinitely. These members are stable (or
@@ -3097,6 +3146,7 @@ const RichEditorPane = ({
if (isLoading && !memo) {
return (
<div className="flex h-full min-w-0 flex-col bg-white">
<EmptyEditorHeader pluginHost={pluginHost} onOpenPluginManager={onOpenPluginManager} />
{selectionActionBar}
<div className="flex min-h-0 flex-1 items-center justify-center text-sm text-slate-500">{t("editor.loading")}</div>
</div>
@@ -3106,6 +3156,7 @@ const RichEditorPane = ({
if (!memo) {
return (
<div className="flex h-full min-w-0 flex-col bg-white">
<EmptyEditorHeader pluginHost={pluginHost} onOpenPluginManager={onOpenPluginManager} />
{selectionActionBar}
<div className="flex min-h-0 flex-1 items-center justify-center px-8 text-center">
<div>
@@ -3637,6 +3688,7 @@ const RichEditorPane = ({
{updateAvailable ? <span className="absolute right-1 top-1 h-1.5 w-1.5 rounded-full bg-emerald-500 ring-2 ring-white" /> : null}
</Button>
</IconTooltip>
<PluginToolbarMenu host={pluginHost} onManage={onOpenPluginManager} />
<ThemeToggle />
{!readOnly && (
<IconTooltip label={t("editor.save")}>
@@ -5,7 +5,7 @@ import { cn } from "@/lib/utils";
const GITHUB_REPOSITORY_URL = "https://github.com/tianma-if/edgeever";
const GitHubMark = ({ className }: { className?: string }) => (
export const GitHubMark = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path
fillRule="evenodd"
+5 -1
View File
@@ -24,6 +24,7 @@ import {
Download,
ExternalLink,
RotateCcw,
Store,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
@@ -287,6 +288,7 @@ export const NotebookPane = ({
onOpenAssets,
onOpenTemplates,
onOpenAiPrompts,
onOpenPluginMarketplace,
onOpenTrash,
onEmptyTrash,
onOpenSettings,
@@ -322,6 +324,7 @@ export const NotebookPane = ({
onOpenAssets: () => void;
onOpenTemplates: () => void;
onOpenAiPrompts: () => void;
onOpenPluginMarketplace: () => void;
onOpenTrash: () => void;
onEmptyTrash: () => void;
onOpenSettings: () => void;
@@ -452,11 +455,12 @@ export const NotebookPane = ({
</header>
<TooltipProvider delayDuration={0} skipDelayDuration={0}>
<nav className="grid shrink-0 grid-cols-2 gap-0.5 border-b border-slate-100 px-2 py-1.5 sm:grid-cols-3 lg:grid-cols-5" aria-label={t("notebookPane.secondaryEntries")}>
<nav className="grid shrink-0 grid-cols-2 gap-0.5 border-b border-slate-100 px-2 py-1.5 sm:grid-cols-3 lg:grid-cols-6" aria-label={t("notebookPane.secondaryEntries")}>
<SidebarShortcutButton icon={<Tags className="h-4 w-4" />} label={t("mobileSheets.tags")} onClick={onOpenTags} />
<SidebarShortcutButton icon={<Archive className="h-4 w-4" />} label={t("mobileSheets.assets")} onClick={onOpenAssets} />
{showTemplateEntry && <SidebarShortcutButton icon={<LayoutTemplate className="h-4 w-4" />} label={t("nav.templates")} onClick={onOpenTemplates} />}
<SidebarShortcutButton icon={<Sparkles className="h-4 w-4" />} label={t("nav.prompts")} onClick={onOpenAiPrompts} />
<SidebarShortcutButton icon={<Store className="h-4 w-4" />} label={t("plugins.marketplace.title")} onClick={onOpenPluginMarketplace} />
<SidebarTrashShortcut active={view === "trash"} onOpenTrash={onOpenTrash} onEmptyTrash={onEmptyTrash} />
</nav>
</TooltipProvider>
@@ -0,0 +1,48 @@
import { ChevronLeft, Store } from "lucide-react";
import { useTranslation } from "react-i18next";
import * as m from "motion/react-m";
import { Button } from "@/components/ui/button";
import { ThemeToggle } from "@/components/ThemeToggle";
import { PluginManagerCard } from "@/components/settings/PluginManagerCard";
import type { EdgeEverPluginHost } from "@/lib/plugins/plugin-host";
import { contentEnterMotion } from "@/lib/motion";
import { WORKSPACE_PAGE_TITLE_CLASSNAME } from "@/lib/workspace-ui";
export const PluginMarketplacePane = ({
host,
onClose,
}: {
host: EdgeEverPluginHost;
onClose: () => void;
}) => {
const { t } = useTranslation();
return (
<div className="flex h-full min-h-0 min-w-0 flex-col overflow-hidden bg-slate-50">
<header className="flex h-[calc(3.5rem+env(safe-area-inset-top))] shrink-0 items-end justify-between border-b border-slate-200 bg-white px-4 pb-3 pt-[env(safe-area-inset-top)] lg:h-16 lg:items-center lg:px-6 lg:pb-0 lg:pt-0">
<div className="flex min-w-0 items-center gap-3">
<Button
size="icon"
variant="ghost"
aria-label={t("common.back")}
onClick={onClose}
className="h-9 w-9 rounded-lg hover:bg-slate-100"
>
<ChevronLeft className="h-5 w-5 text-slate-500" />
</Button>
<h1 className={`flex min-w-0 items-center gap-2 ${WORKSPACE_PAGE_TITLE_CLASSNAME}`}>
<Store className="h-4 w-4 shrink-0 text-emerald-700" />
<span className="truncate text-slate-900">{t("plugins.marketplace.title")}</span>
</h1>
</div>
<ThemeToggle className="inline-flex" showLabel />
</header>
<main className="min-h-0 flex-1 overflow-y-auto px-4 py-5 sm:px-6 lg:py-7">
<m.div className="mx-auto w-full max-w-5xl" {...contentEnterMotion}>
<PluginManagerCard host={host} />
</m.div>
</main>
</div>
);
};
+13 -1
View File
@@ -38,6 +38,8 @@ import { AiGenerationPreferenceCard } from "./settings/AiGenerationPreferenceCar
import { ThemeToggle } from "./ThemeToggle";
import type { AuthUser } from "@edgeever/shared";
import { contentEnterMotion } from "@/lib/motion";
import type { EdgeEverPluginHost } from "@/lib/plugins/plugin-host";
import { PluginToolbarMenu } from "./plugins/PluginToolbarMenu";
interface SettingsPaneProps {
onClose: () => void;
@@ -56,6 +58,8 @@ interface SettingsPaneProps {
isOwner: boolean;
user: AuthUser | null;
refreshWorkspaceAfterImport: () => Promise<void>;
pluginHost: EdgeEverPluginHost;
onOpenPluginMarketplace: () => void;
}
// Slate and brand color variables already switch values with the root theme.
@@ -95,6 +99,8 @@ export const SettingsPane = ({
isOwner,
user,
refreshWorkspaceAfterImport,
pluginHost,
onOpenPluginMarketplace,
}: SettingsPaneProps) => {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<TabKey>("general");
@@ -276,7 +282,13 @@ export const SettingsPane = ({
</h1>
</div>
</div>
<ThemeToggle className="inline-flex" showLabel />
<div className="flex items-center gap-1">
<PluginToolbarMenu
host={pluginHost}
onManage={onOpenPluginMarketplace}
/>
<ThemeToggle className="inline-flex" showLabel />
</div>
</header>
<div className="flex flex-1 min-h-0 min-w-0 bg-slate-50/50">
+70 -5
View File
@@ -90,6 +90,7 @@ import { useWorkspaceRoute } from "@/hooks/useWorkspaceRoute";
import { useWorkspacePreferences } from "@/hooks/useWorkspacePreferences";
import { useWorkspaceSelection } from "@/hooks/useWorkspaceSelection";
import { useWorkspaceQueuedSync } from "@/hooks/useWorkspaceQueuedSync";
import { EdgeEverPluginHost } from "@/lib/plugins/plugin-host";
const isDesktopViewport = () => window.matchMedia("(min-width: 1024px)").matches;
const PULL_TO_REFRESH_TRIGGER_PX = 72;
@@ -138,6 +139,7 @@ const getVerticalScrollContainer = (target: EventTarget | null) => {
const EditorPane = lazy(() => import("./EditorPane").then((module) => ({ default: module.EditorPane })));
const AssetsPane = lazy(() => import("./AssetsPane").then((module) => ({ default: module.AssetsPane })));
const SettingsPane = lazy(() => import("./SettingsPane").then((module) => ({ default: module.SettingsPane })));
const PluginMarketplacePane = lazy(() => import("./PluginMarketplacePane").then((module) => ({ default: module.PluginMarketplacePane })));
const NotebookPane = lazy(() => import("./NotebookPane").then((module) => ({ default: module.NotebookPane })));
const EvernoteImportGuidePane = lazy(() =>
import("./EvernoteImportGuidePane").then((module) => ({ default: module.EvernoteImportGuidePane }))
@@ -665,6 +667,7 @@ export const WorkspaceApp = ({
navigateHome: navigateWorkspaceHome,
navigateTrash: navigateWorkspaceTrash,
navigateSettings: navigateWorkspaceSettings,
navigatePlugins: navigateWorkspacePlugins,
navigateTemplates: navigateWorkspaceTemplates,
navigateAiPrompts: navigateWorkspaceAiPrompts,
} = useWorkspaceRoute();
@@ -674,11 +677,12 @@ export const WorkspaceApp = ({
);
const repository = useMemo(() => createRepository(localDataScope), [localDataScope]);
const isInitialSettingsRoute = route.isSettings;
const isInitialPluginsRoute = route.isPlugins;
const isInitialTemplatesRoute = route.isTemplates;
const isInitialAiPromptsRoute = route.isAiPrompts;
const isInitialMobileEditorReturn = Boolean(route.mobileEditorReturnMemoId);
const isTrashRoute = route.isTrash;
const [activePane, setActivePane] = useState<Pane>(() => ((isInitialSettingsRoute || isInitialTemplatesRoute || isInitialAiPromptsRoute) && !isInitialMobileEditorReturn ? "editor" : "memos"));
const [activePane, setActivePane] = useState<Pane>(() => ((isInitialSettingsRoute || isInitialPluginsRoute || isInitialTemplatesRoute || isInitialAiPromptsRoute) && !isInitialMobileEditorReturn ? "editor" : "memos"));
const [memoView, setMemoView] = useState<MemoView>(() => (isTrashRoute ? "trash" : "notebook"));
const {
beginMemoSelection,
@@ -708,6 +712,26 @@ export const WorkspaceApp = ({
const [notebookDeleteConfirmation, setNotebookDeleteConfirmation] = useState<Notebook | null>(null);
const [appNoticeDialog, setAppNoticeDialog] = useState<AppNoticeDialogState | null>(null);
const [demoResetConfirmationOpen, setDemoResetConfirmationOpen] = useState(false);
const pluginHost = useMemo(() => new EdgeEverPluginHost({
repository,
scope: localDataScope,
onNotice: (message) => setAppNoticeDialog({ title: t("plugins.noticeTitle"), description: message }),
onWorkspaceChanged: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["memos"] }),
queryClient.invalidateQueries({ queryKey: ["memo"] }),
queryClient.invalidateQueries({ queryKey: ["notebooks"] }),
queryClient.invalidateQueries({ queryKey: ["tags"] }),
]);
},
}), [localDataScope, queryClient, repository, t]);
useEffect(() => {
void pluginHost.activateEnabled();
return () => {
void pluginHost.dispose();
};
}, [pluginHost]);
const resetDemoMutation = useMutation({
mutationFn: () => api.resetDemo(),
@@ -747,9 +771,11 @@ export const WorkspaceApp = ({
shortcutSettings,
syncIntervalMs,
} = useWorkspacePreferences();
const [rightView, setRightView] = useState<"editor" | "settings" | "assets" | "tags" | "templates" | "ai-prompts" | "evernote-migration">(() =>
const [rightView, setRightView] = useState<"editor" | "settings" | "plugins" | "assets" | "tags" | "templates" | "ai-prompts" | "evernote-migration">(() =>
isInitialSettingsRoute
? "settings"
: isInitialPluginsRoute
? "plugins"
: isInitialTemplatesRoute
? "templates"
: isInitialAiPromptsRoute
@@ -1083,6 +1109,14 @@ export const WorkspaceApp = ({
return;
}
if (route.isPlugins) {
skipNextHomeRouteSyncRef.current = false;
setRightView("plugins");
setMobileBottomNavActive("home");
setActivePane("editor");
return;
}
if (route.isTemplates) {
skipNextHomeRouteSyncRef.current = false;
setRightView("templates");
@@ -1107,7 +1141,7 @@ export const WorkspaceApp = ({
setMemoView(isTrashRoute ? "trash" : "notebook");
setRightView("editor");
setMobileBottomNavActive("home");
}, [isTrashRoute, route.isSettings, route.isTemplates, route.isAiPrompts]);
}, [isTrashRoute, route.isSettings, route.isPlugins, route.isTemplates, route.isAiPrompts]);
useEffect(() => {
if (window.edgeeverDesktop?.isAvailable) {
@@ -2211,6 +2245,14 @@ export const WorkspaceApp = ({
setActivePane("editor");
};
const handleOpenPluginManager = () => {
clearHiddenMobileSearch();
navigateWorkspacePlugins();
setRightView("plugins");
setMobileBottomNavActive("home");
setActivePane("editor");
};
const handleCloseAssets = () => {
navigateWorkspaceHome();
setRightView("editor");
@@ -2239,6 +2281,15 @@ export const WorkspaceApp = ({
}
};
const handleClosePluginMarketplace = () => {
navigateWorkspaceHome();
setRightView("editor");
setMobileBottomNavActive("home");
if (!isDesktopViewport()) {
setActivePane("memos");
}
};
const updateDesktopFocusMode = useCallback((enabled: boolean) => {
runWorkspaceViewTransition(() => {
setDesktopFocusMode(enabled);
@@ -2361,6 +2412,11 @@ export const WorkspaceApp = ({
return true;
}
if (rightView === "plugins") {
handleClosePluginMarketplace();
return true;
}
if (rightView === "tags") {
handleCloseAssets();
return true;
@@ -2601,6 +2657,8 @@ export const WorkspaceApp = ({
const rightPaneLoadingLabel =
rightView === "settings"
? t("workspace.loading.settings")
: rightView === "plugins"
? t("plugins.marketplace.loading")
: rightView === "assets"
? t("workspace.loading.assets")
: rightView === "tags"
@@ -2701,6 +2759,7 @@ export const WorkspaceApp = ({
onOpenTags={handleOpenTags}
onOpenTemplates={handleOpenTemplates}
onOpenAiPrompts={handleOpenAiPrompts}
onOpenPluginMarketplace={handleOpenPluginManager}
onOpenSettings={handleOpenSettings}
onOpenTrash={() => {
navigateWorkspaceTrash();
@@ -2887,7 +2946,11 @@ export const WorkspaceApp = ({
refreshWorkspaceAfterImport={async () => {
await refreshWorkspaceFromServer("manual");
}}
pluginHost={pluginHost}
onOpenPluginMarketplace={handleOpenPluginManager}
/>
) : rightView === "plugins" ? (
<PluginMarketplacePane host={pluginHost} onClose={handleClosePluginMarketplace} />
) : rightView === "assets" ? (
<AssetsPane onClose={handleCloseAssets} repository={repository} />
) : rightView === "tags" ? (
@@ -2913,8 +2976,10 @@ export const WorkspaceApp = ({
<EvernoteImportGuidePane onClose={() => setRightView("settings")} />
) : (
<EditorPane
memo={selectedMemo}
repository={repository}
memo={selectedMemo}
repository={repository}
pluginHost={pluginHost}
onOpenPluginManager={handleOpenPluginManager}
onOpenAiPrompts={handleOpenAiPrompts}
desktopFocusMode={desktopFocusModeActive}
onToggleDesktopFocusMode={toggleDesktopFocusMode}
@@ -0,0 +1,49 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import type { EdgeEverPluginHost, RegisteredPluginPanel } from "@/lib/plugins/plugin-host";
export const PluginPanelDialog = ({ host, panel, onClose }: {
host: EdgeEverPluginHost;
panel: RegisteredPluginPanel | null;
onClose: () => void;
}) => {
const { t } = useTranslation();
const containerRef = useRef<HTMLDivElement>(null);
const [mountError, setMountError] = useState<string | null>(null);
const panelPluginId = panel?.pluginId ?? null;
const panelId = panel?.id ?? null;
useEffect(() => {
const container = containerRef.current;
if (!container || !panelPluginId || !panelId) return;
let disposed = false;
let disposePanel: (() => void) | null = null;
setMountError(null);
container.replaceChildren();
void host.mountPanel(panelPluginId, panelId, container).then((dispose) => {
if (disposed) dispose();
else disposePanel = dispose;
}).catch((error: unknown) => {
if (!disposed) setMountError(error instanceof Error ? error.message : String(error));
});
return () => {
disposed = true;
disposePanel?.();
container.replaceChildren();
};
}, [host, panelId, panelPluginId]);
return (
<Dialog open={Boolean(panel)} onOpenChange={(open) => { if (!open) onClose(); }}>
<DialogContent className="max-h-[85vh] max-w-3xl overflow-y-auto">
<DialogHeader>
<DialogTitle>{panel?.title ?? t("plugins.panel")}</DialogTitle>
<DialogDescription>{t("plugins.panelDescription")}</DialogDescription>
</DialogHeader>
{mountError ? <div role="alert" className="rounded-lg border border-rose-200 bg-rose-50 p-3 text-xs text-rose-700">{mountError}</div> : null}
<div ref={containerRef} className="min-h-40 rounded-lg border border-slate-200 bg-white p-4 text-sm text-slate-700" />
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,158 @@
import { useEffect, useState, useSyncExternalStore } from "react";
import { Clock3, LoaderCircle, PanelRightOpen, Play, Puzzle, Settings2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { PluginPanelDialog } from "@/components/plugins/PluginPanelDialog";
import type {
EdgeEverPluginHost,
RegisteredPluginAction,
RegisteredPluginPanel,
} from "@/lib/plugins/plugin-host";
const actionKey = (action: RegisteredPluginAction) => `${action.type}:${action.pluginId}:${action.id}`;
export const PluginToolbarMenu = ({ host, onManage }: {
host: EdgeEverPluginHost;
onManage: () => void;
}) => {
const { t } = useTranslation();
const snapshot = useSyncExternalStore(host.subscribe, host.getSnapshot, host.getSnapshot);
const [open, setOpen] = useState(false);
const [pendingAction, setPendingAction] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [activePanel, setActivePanel] = useState<RegisteredPluginPanel | null>(null);
const activePanelPluginId = activePanel?.pluginId ?? null;
const activePanelId = activePanel?.id ?? null;
const groups = snapshot.extensions.flatMap((extension) => {
if (!extension.enabled || extension.manifest.type !== "plugin") return [];
const actions: RegisteredPluginAction[] = [
...snapshot.commands.filter((command) => command.pluginId === extension.manifest.id).map((command) => ({ ...command, type: "command" as const })),
...snapshot.panels.filter((panel) => panel.pluginId === extension.manifest.id).map((panel) => ({ ...panel, type: "panel" as const })),
];
return actions.length ? [{ pluginId: extension.manifest.id, name: extension.manifest.name, actions }] : [];
});
const hasActions = groups.length > 0;
const pluginNames = new Map(snapshot.extensions.map((extension) => [extension.manifest.id, extension.manifest.name]));
const activePanelRegistered = Boolean(activePanelPluginId && activePanelId && snapshot.panels.some(
(panel) => panel.pluginId === activePanelPluginId && panel.id === activePanelId
));
useEffect(() => {
if (activePanelId && activePanelPluginId && !activePanelRegistered) setActivePanel(null);
}, [activePanelId, activePanelPluginId, activePanelRegistered]);
const openPanel = (action: RegisteredPluginAction) => {
const panel = snapshot.panels.find((item) => item.pluginId === action.pluginId && item.id === action.id);
if (!panel) return;
setOpen(false);
setActivePanel(panel);
};
const runAction = async (action: RegisteredPluginAction) => {
if (action.type === "panel") {
openPanel(action);
return;
}
const key = actionKey(action);
setPendingAction(key);
setError(null);
try {
await host.runCommand(action.pluginId, action.id);
setOpen(false);
} catch (actionError) {
setError(actionError instanceof Error ? actionError.message : String(actionError));
} finally {
setPendingAction(null);
}
};
const renderAction = (action: RegisteredPluginAction, prefix: string, pluginName?: string) => {
const key = actionKey(action);
return (
<DropdownMenuItem
key={`${prefix}:${key}`}
className="gap-2"
disabled={pendingAction !== null}
onSelect={(event) => {
event.preventDefault();
void runAction(action);
}}
>
{pendingAction === key ? (
<LoaderCircle className="h-4 w-4 animate-spin text-emerald-600" />
) : action.type === "panel" ? (
<PanelRightOpen className="h-4 w-4 text-slate-500" />
) : (
<Play className="h-4 w-4 text-slate-500" />
)}
<span className="min-w-0 flex-1 truncate">{action.title}</span>
{pluginName ? <span className="max-w-24 truncate text-[10px] text-slate-400">{pluginName}</span> : null}
</DropdownMenuItem>
);
};
return (
<>
<DropdownMenu open={open} onOpenChange={(nextOpen) => { setOpen(nextOpen); if (nextOpen) setError(null); }}>
<TooltipProvider delayDuration={0} skipDelayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
className="relative hidden h-8 w-8 text-slate-500 hover:bg-slate-100 hover:text-slate-950 focus-visible:ring-2 focus-visible:ring-emerald-500/70 lg:inline-flex"
size="icon"
variant="ghost"
aria-label={t("plugins.toolbar.open")}
>
<Puzzle className="h-4 w-4" />
{hasActions ? <span className="absolute right-1 top-1 h-1.5 w-1.5 rounded-full bg-emerald-500 ring-2 ring-white" aria-hidden="true" /> : null}
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom">{t("plugins.toolbar.open")}</TooltipContent>
</Tooltip>
</TooltipProvider>
<DropdownMenuContent align="end" className="w-72">
{snapshot.recentActions.length ? (
<>
<DropdownMenuLabel className="flex items-center gap-2 text-xs text-slate-500">
<Clock3 className="h-3.5 w-3.5" />
{t("plugins.toolbar.recent")}
</DropdownMenuLabel>
{snapshot.recentActions.map((action) => renderAction(action, "recent", pluginNames.get(action.pluginId)))}
<DropdownMenuSeparator />
</>
) : null}
{hasActions ? groups.map((group, index) => (
<div key={group.pluginId}>
{index > 0 ? <DropdownMenuSeparator /> : null}
<DropdownMenuLabel className="truncate text-xs text-slate-500">{group.name}</DropdownMenuLabel>
{group.actions.map((action) => renderAction(action, group.pluginId))}
</div>
)) : (
<div className="px-2 py-5 text-center text-xs leading-5 text-slate-500">{t("plugins.toolbar.empty")}</div>
)}
{error ? <div role="alert" className="mx-1 my-1 rounded bg-rose-50 px-2 py-1.5 text-xs text-rose-700">{error}</div> : null}
<DropdownMenuSeparator />
<DropdownMenuItem className="gap-2" onSelect={() => { setOpen(false); onManage(); }}>
<Settings2 className="h-4 w-4 text-slate-500" />
{t("plugins.toolbar.manage")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<PluginPanelDialog host={host} panel={activePanel} onClose={() => setActivePanel(null)} />
</>
);
};
@@ -0,0 +1,266 @@
import { useEffect, useState, useSyncExternalStore } from "react";
import { BadgeCheck, Boxes, Download, ExternalLink, PanelRightOpen, Play, Puzzle, RefreshCw, Store, Trash2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import type { EdgeEverPluginHost, RegisteredPluginPanel } from "@/lib/plugins/plugin-host";
import { PluginPanelDialog } from "@/components/plugins/PluginPanelDialog";
import { loadPluginMarketplace } from "@/lib/plugins/plugin-marketplace";
import { GitHubMark } from "@/components/GitHubRepositoryLink";
const permissionLabel = (permission: string) => permission.replace(":", " · ");
export const PluginManagerCard = ({ host }: { host: EdgeEverPluginHost }) => {
const { t } = useTranslation();
const snapshot = useSyncExternalStore(host.subscribe, host.getSnapshot, host.getSnapshot);
const [manifestUrl, setManifestUrl] = useState("");
const [pendingId, setPendingId] = useState<string | null>(null);
const [installing, setInstalling] = useState(false);
const [error, setError] = useState<string | null>(null);
const [activePanel, setActivePanel] = useState<RegisteredPluginPanel | null>(null);
const marketplaceQuery = useQuery({ queryKey: ["plugin-marketplace", "v1"], queryFn: () => loadPluginMarketplace(), staleTime: 5 * 60_000 });
const activePanelPluginId = activePanel?.pluginId ?? null;
const activePanelId = activePanel?.id ?? null;
const activePanelRegistered = Boolean(activePanelPluginId && activePanelId && snapshot.panels.some(
(panel) => panel.pluginId === activePanelPluginId && panel.id === activePanelId
));
useEffect(() => {
if (activePanelId && activePanelPluginId && !activePanelRegistered) setActivePanel(null);
}, [activePanelId, activePanelPluginId, activePanelRegistered]);
const install = async () => {
if (!manifestUrl.trim()) return;
setInstalling(true);
setError(null);
try {
await host.installFromSource(manifestUrl.trim());
setManifestUrl("");
} catch (installError) {
setError(installError instanceof Error ? installError.message : String(installError));
} finally {
setInstalling(false);
}
};
const run = async (actionId: string, action: () => Promise<void>) => {
setPendingId(actionId);
setError(null);
try {
await action();
} catch (actionError) {
setError(actionError instanceof Error ? actionError.message : String(actionError));
} finally {
setPendingId(null);
}
};
return (
<Card className="w-full min-w-0 shadow-none">
<CardHeader className="p-4 sm:p-5">
<CardTitle className="flex items-center gap-2 text-sm">
<Puzzle className="h-4 w-4 text-emerald-700" />
{t("plugins.title")}
</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 p-4 pt-0 sm:px-5 sm:pb-5">
<section className="grid gap-3 rounded-xl border border-emerald-200 bg-emerald-50/40 p-3.5">
<div className="flex items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2 text-sm font-semibold text-slate-900">
<Store className="h-4 w-4 text-emerald-700" />
{t("plugins.marketplace.title")}
</div>
<p className="mt-1 text-xs leading-5 text-slate-500">{t("plugins.marketplace.description")}</p>
</div>
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
aria-label={t("plugins.marketplace.refresh")}
disabled={marketplaceQuery.isFetching}
onClick={() => void marketplaceQuery.refetch()}
>
<RefreshCw className={`h-3.5 w-3.5 ${marketplaceQuery.isFetching ? "animate-spin" : ""}`} />
</Button>
</div>
{marketplaceQuery.isLoading ? (
<div className="py-4 text-center text-xs text-slate-500">{t("plugins.marketplace.loading")}</div>
) : marketplaceQuery.error ? (
<div role="alert" className="rounded-lg border border-rose-200 bg-rose-50 p-3 text-xs text-rose-700">
{marketplaceQuery.error instanceof Error ? marketplaceQuery.error.message : String(marketplaceQuery.error)}
</div>
) : (
<div className="grid gap-2 md:grid-cols-2">
{(marketplaceQuery.data?.entries ?? []).map((entry) => {
const installed = snapshot.extensions.find((extension) => extension.manifest.id === entry.id);
const currentVerified = installed?.source.verified && installed.manifest.version === entry.verification.version;
const actionId = `marketplace:${entry.id}`;
return (
<article key={entry.id} className="flex min-w-0 flex-col rounded-lg border border-emerald-100 bg-white p-3">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<span className="truncate text-sm font-semibold text-slate-900">{entry.name}</span>
<BadgeCheck className="h-4 w-4 shrink-0 text-emerald-600" aria-label={t("plugins.marketplace.verified")} />
</div>
<div className="mt-0.5 text-[10px] text-slate-400">{entry.author} · {entry.category} · v{entry.verification.version}</div>
</div>
<a
href={entry.repositoryUrl}
target="_blank"
rel="noreferrer"
className="rounded p-1 text-slate-400 hover:bg-slate-100 hover:text-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/70"
aria-label={t("plugins.marketplace.openRepository", { name: entry.name })}
>
<ExternalLink className="h-3.5 w-3.5" />
</a>
</div>
<p className="mt-2 line-clamp-2 text-xs leading-5 text-slate-500">{entry.description}</p>
<Button
size="sm"
variant={currentVerified ? "soft" : "outline"}
className="mt-3 h-8 gap-1.5 text-xs"
disabled={Boolean(currentVerified) || pendingId === actionId}
onClick={() => void run(actionId, async () => { await host.installMarketplaceEntry(entry); })}
>
<Download className="h-3.5 w-3.5" />
{pendingId === actionId
? t("plugins.installing")
: currentVerified
? t("plugins.marketplace.installed")
: installed
? t("plugins.marketplace.installVerified")
: t("plugins.install")}
</Button>
</article>
);
})}
</div>
)}
</section>
<div className="flex flex-col gap-2 sm:flex-row">
<Input
aria-label={t("plugins.installSource")}
value={manifestUrl}
onChange={(event) => setManifestUrl(event.target.value)}
placeholder={t("plugins.sourcePlaceholder")}
/>
<Button className="gap-1.5 sm:shrink-0" disabled={installing || !manifestUrl.trim()} onClick={() => void install()}>
<Download className="h-4 w-4" />
{installing ? t("plugins.installing") : t("plugins.install")}
</Button>
</div>
{error ? <div role="alert" className="rounded-lg border border-rose-200 bg-rose-50 p-3 text-xs text-rose-700">{error}</div> : null}
{snapshot.extensions.length === 0 ? (
<div className="rounded-lg border border-dashed border-slate-200 p-5 text-center text-xs text-slate-500">
{t("plugins.empty")}
</div>
) : (
<div className="grid gap-2">
{snapshot.extensions.map((extension) => {
const id = extension.manifest.id;
const commands = snapshot.commands.filter((command) => command.pluginId === id);
const panels = snapshot.panels.filter((panel) => panel.pluginId === id);
return (
<section key={id} className="rounded-lg border border-slate-200 bg-white p-3.5">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-sm font-semibold text-slate-900">{extension.manifest.name}</span>
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-slate-500">
{extension.manifest.type}
</span>
<span className="text-[11px] text-slate-400">v{extension.manifest.version}</span>
<span className={`inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[10px] ${extension.source.verified ? "bg-emerald-50 text-emerald-700" : "bg-amber-50 text-amber-700"}`}>
{extension.source.verified ? <BadgeCheck className="h-3 w-3" /> : extension.source.kind === "github" ? <GitHubMark className="h-3 w-3" /> : null}
{t(`plugins.sources.${extension.source.verified ? "verified" : extension.source.kind}`)}
</span>
</div>
{extension.manifest.description ? <p className="mt-1 text-xs leading-5 text-slate-500">{extension.manifest.description}</p> : null}
{extension.source.repositoryUrl ? (
<a className="mt-1 inline-flex items-center gap-1 text-[11px] text-slate-400 hover:text-emerald-700" href={extension.source.repositoryUrl} target="_blank" rel="noreferrer">
<GitHubMark className="h-3 w-3" />
{extension.source.repositoryUrl.replace("https://github.com/", "")}
</a>
) : null}
</div>
<Switch
aria-label={t("plugins.toggle", { name: extension.manifest.name })}
checked={extension.enabled}
disabled={pendingId === id}
onCheckedChange={(enabled) => void run(id, () => host.setEnabled(id, enabled))}
/>
</div>
{extension.manifest.type === "plugin" && extension.manifest.permissions.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-1">
{extension.manifest.permissions.map((permission) => (
<span key={permission} className="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] text-slate-600">
{permissionLabel(permission)}
</span>
))}
</div>
) : null}
{extension.error ? <div className="mt-2 text-xs text-rose-600">{extension.error}</div> : null}
<div className="mt-3 flex flex-wrap items-center gap-2">
{commands.map((command) => (
<Button
key={command.id}
size="sm"
variant="outline"
className="h-8 gap-1.5 text-xs"
disabled={pendingId === `${id}:${command.id}`}
onClick={() => void run(`${id}:${command.id}`, () => host.runCommand(id, command.id))}
>
<Play className="h-3.5 w-3.5" />
{command.title}
</Button>
))}
{panels.map((panel) => (
<Button
key={panel.id}
size="sm"
variant="outline"
className="h-8 gap-1.5 text-xs"
onClick={() => setActivePanel(panel)}
>
<PanelRightOpen className="h-3.5 w-3.5" />
{panel.title}
</Button>
))}
<Button
size="sm"
variant="ghost"
className="ml-auto h-8 gap-1.5 text-xs text-rose-600 hover:bg-rose-50 hover:text-rose-700"
disabled={pendingId === `remove:${id}`}
onClick={() => void run(`remove:${id}`, () => host.uninstall(id))}
>
<Trash2 className="h-3.5 w-3.5" />
{t("plugins.uninstall")}
</Button>
</div>
</section>
);
})}
</div>
)}
<div className="flex items-start gap-2 rounded-lg bg-slate-100 p-3 text-xs leading-5 text-slate-600">
<Boxes className="mt-0.5 h-4 w-4 shrink-0 text-slate-400" />
<span>{t("plugins.localOnly")}</span>
</div>
<PluginPanelDialog host={host} panel={activePanel} onClose={() => setActivePanel(null)} />
</CardContent>
</Card>
);
};
@@ -6,11 +6,13 @@ describe("workspace route resolution", () => {
expect(resolveWorkspaceRoute("/", "")).toMatchObject({
isTrash: false,
isSettings: false,
isPlugins: false,
isTemplates: false,
isAiPrompts: false,
});
expect(resolveWorkspaceRoute("/", "?view=trash").isTrash).toBe(true);
expect(resolveWorkspaceRoute("/settings", "").isSettings).toBe(true);
expect(resolveWorkspaceRoute("/plugins", "").isPlugins).toBe(true);
expect(resolveWorkspaceRoute("/templates", "").isTemplates).toBe(true);
expect(resolveWorkspaceRoute("/ai-prompts", "").isAiPrompts).toBe(true);
});
+8
View File
@@ -3,6 +3,7 @@ import { useLocation, useNavigate, type NavigateOptions } from "react-router";
import { MOBILE_EDITOR_RETURN_PARAM } from "@/lib/mobile-editor";
export const WORKSPACE_SETTINGS_PATH = "/settings";
export const WORKSPACE_PLUGINS_PATH = "/plugins";
export const WORKSPACE_TEMPLATES_PATH = "/templates";
export const WORKSPACE_AI_PROMPTS_PATH = "/ai-prompts";
export const WORKSPACE_TRASH_SEARCH = "?view=trash";
@@ -11,6 +12,7 @@ export type WorkspaceRouteState = {
pathname: string;
search: string;
isSettings: boolean;
isPlugins: boolean;
isTemplates: boolean;
isAiPrompts: boolean;
isTrash: boolean;
@@ -21,6 +23,7 @@ export const resolveWorkspaceRoute = (pathname: string, search: string): Workspa
pathname,
search,
isSettings: pathname === WORKSPACE_SETTINGS_PATH,
isPlugins: pathname === WORKSPACE_PLUGINS_PATH,
isTemplates: pathname === WORKSPACE_TEMPLATES_PATH,
isAiPrompts: pathname === WORKSPACE_AI_PROMPTS_PATH,
isTrash: pathname === "/" && search === WORKSPACE_TRASH_SEARCH,
@@ -47,6 +50,10 @@ export const useWorkspaceRoute = () => {
if (!route.isSettings) navigate(WORKSPACE_SETTINGS_PATH);
}, [navigate, route.isSettings]);
const navigatePlugins = useCallback(() => {
if (!route.isPlugins) navigate(WORKSPACE_PLUGINS_PATH);
}, [navigate, route.isPlugins]);
const navigateTemplates = useCallback(() => {
if (!route.isTemplates) navigate(WORKSPACE_TEMPLATES_PATH);
}, [navigate, route.isTemplates]);
@@ -59,6 +66,7 @@ export const useWorkspaceRoute = () => {
route,
navigateHome,
navigateSettings,
navigatePlugins,
navigateTemplates,
navigateAiPrompts,
navigateTrash,
@@ -0,0 +1,71 @@
import { describe, expect, test } from "bun:test";
import { downloadGithubExtension, parseGithubRepositoryUrl } from "./github-plugin-distribution.ts";
const manifest = {
type: "plugin",
id: "org.edgeever.github-test",
name: "GitHub Test",
version: "1.2.3",
apiVersion: "1",
entry: "./main.js",
permissions: ["ui:notices"],
};
describe("GitHub plugin distribution", () => {
test("accepts only canonical public GitHub repository URLs", () => {
expect(parseGithubRepositoryUrl("https://github.com/example/edgeever-plugin.git")).toEqual({
owner: "example",
repository: "edgeever-plugin",
repositoryUrl: "https://github.com/example/edgeever-plugin",
});
expect(parseGithubRepositoryUrl("https://github.com/example/edgeever-plugin/tree/main")).toBeNull();
expect(parseGithubRepositoryUrl("https://gitlab.com/example/edgeever-plugin")).toBeNull();
});
test("downloads the versioned release bundle and falls back to a v-prefixed tag", async () => {
const calls = [];
const request = async (input) => {
const url = String(input);
calls.push(url);
if (url === "https://api.github.com/repos/example/edgeever-plugin") {
return Response.json({ default_branch: "main", html_url: "https://github.com/example/edgeever-plugin" });
}
if (url.includes("raw.githubusercontent.com")) return Response.json(manifest);
if (url.endsWith("/releases/tags/1.2.3")) return new Response(null, { status: 404 });
if (url.endsWith("/releases/tags/v1.2.3")) {
return Response.json({
tag_name: "v1.2.3",
draft: false,
assets: [
{ id: 1, name: "manifest.json", size: 512, url: "https://api.github.com/assets/1", browser_download_url: "https://github.com/download/manifest.json" },
{ id: 2, name: "main.js", size: 128, url: "https://api.github.com/assets/2", browser_download_url: "https://github.com/download/main.js" },
],
});
}
if (url === "https://api.github.com/assets/1") return new Response(JSON.stringify(manifest));
if (url === "https://api.github.com/assets/2") return new Response("export default { activate() {} };");
return new Response(null, { status: 500 });
};
const downloaded = await downloadGithubExtension("https://github.com/example/edgeever-plugin", request);
expect(downloaded.releaseTag).toBe("v1.2.3");
expect(downloaded.pluginPackage?.pluginId).toBe("org.edgeever.github-test");
expect(downloaded.pluginPackage?.mainJs).toContain("activate");
expect(downloaded.checksums.mainJs).toHaveLength(64);
expect(calls).toContain("https://api.github.com/repos/example/edgeever-plugin/releases/tags/1.2.3");
});
test("rejects a release without a bundled main.js asset", async () => {
const request = async (input) => {
const url = String(input);
if (url === "https://api.github.com/repos/example/edgeever-plugin") return Response.json({ default_branch: "main" });
if (url.includes("raw.githubusercontent.com")) return Response.json(manifest);
if (url.endsWith("/releases/tags/1.2.3")) {
return Response.json({ tag_name: "1.2.3", draft: false, assets: [{ id: 1, name: "manifest.json", size: 512, url: "asset", browser_download_url: "asset" }] });
}
return new Response(null, { status: 404 });
};
await expect(downloadGithubExtension("https://github.com/example/edgeever-plugin", request)).rejects.toThrow("missing main.js");
});
});
@@ -0,0 +1,187 @@
import { parseExtensionManifest, type ExtensionManifest, type PluginManifest } from "@edgeever/plugin-api";
import type { CachedPluginPackage } from "@/lib/plugins/plugin-package-store";
const GITHUB_API_VERSION = "2022-11-28";
const MAX_MAIN_JS_BYTES = 5 * 1024 * 1024;
const MAX_STYLES_CSS_BYTES = 1024 * 1024;
export interface GithubRepositoryCoordinates {
owner: string;
repository: string;
repositoryUrl: string;
}
export interface GithubDownloadedExtension {
manifest: ExtensionManifest;
manifestUrl: string;
repositoryUrl: string;
releaseTag: string | null;
pluginPackage: CachedPluginPackage | null;
checksums: Partial<CachedPluginPackage["checksums"]>;
}
type GithubRepositoryResponse = {
default_branch?: string;
html_url?: string;
};
type GithubReleaseAsset = {
id: number;
name: string;
size: number;
url: string;
browser_download_url: string;
digest?: string | null;
};
type GithubReleaseResponse = {
tag_name: string;
draft: boolean;
assets: GithubReleaseAsset[];
};
export const parseGithubRepositoryUrl = (input: string): GithubRepositoryCoordinates | null => {
let url: URL;
try {
url = new URL(input.trim());
} catch {
return null;
}
if (url.protocol !== "https:" || url.hostname.toLocaleLowerCase() !== "github.com") return null;
const parts = url.pathname.split("/").filter(Boolean);
if (parts.length !== 2) return null;
const owner = parts[0];
const repository = parts[1].replace(/\.git$/i, "");
if (!/^[a-z0-9](?:[a-z0-9-]{0,38})$/i.test(owner) || !/^[a-z0-9._-]+$/i.test(repository)) return null;
return { owner, repository, repositoryUrl: `https://github.com/${owner}/${repository}` };
};
export const sha256Hex = async (value: string | ArrayBuffer) => {
const bytes = typeof value === "string" ? new TextEncoder().encode(value) : value;
const digest = await crypto.subtle.digest("SHA-256", bytes);
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
};
const requestJson = async <T>(request: typeof fetch, url: string): Promise<T> => {
const response = await request(url, {
cache: "no-store",
credentials: "omit",
headers: { Accept: "application/vnd.github+json", "X-GitHub-Api-Version": GITHUB_API_VERSION },
});
if (!response.ok) throw new Error(`GitHub request failed with HTTP ${response.status}.`);
return response.json() as Promise<T>;
};
const findRelease = async (request: typeof fetch, coordinates: GithubRepositoryCoordinates, version: string) => {
for (const tag of [version, `v${version}`]) {
const response = await request(
`https://api.github.com/repos/${coordinates.owner}/${coordinates.repository}/releases/tags/${encodeURIComponent(tag)}`,
{
cache: "no-store",
credentials: "omit",
headers: { Accept: "application/vnd.github+json", "X-GitHub-Api-Version": GITHUB_API_VERSION },
}
);
if (response.status === 404) continue;
if (!response.ok) throw new Error(`GitHub release request failed with HTTP ${response.status}.`);
const release = await response.json() as GithubReleaseResponse;
if (release.draft) throw new Error("GitHub draft releases cannot be installed.");
return release;
}
throw new Error(`GitHub Release ${version} or v${version} was not found.`);
};
const requireAsset = (release: GithubReleaseResponse, name: string) => {
const asset = release.assets.find((candidate) => candidate.name === name);
if (!asset) throw new Error(`GitHub Release ${release.tag_name} is missing ${name}.`);
return asset;
};
const downloadAsset = async (request: typeof fetch, asset: GithubReleaseAsset, maximumBytes: number) => {
if (asset.size > maximumBytes) throw new Error(`${asset.name} exceeds the allowed package size.`);
const response = await request(asset.url, {
credentials: "omit",
redirect: "follow",
headers: { Accept: "application/octet-stream", "X-GitHub-Api-Version": GITHUB_API_VERSION },
});
if (!response.ok) throw new Error(`GitHub asset ${asset.name} failed with HTTP ${response.status}.`);
const buffer = await response.arrayBuffer();
if (buffer.byteLength > maximumBytes) throw new Error(`${asset.name} exceeds the allowed package size.`);
const checksum = await sha256Hex(buffer);
if (asset.digest?.startsWith("sha256:") && asset.digest.slice(7).toLocaleLowerCase() !== checksum) {
throw new Error(`${asset.name} does not match GitHub's SHA-256 digest.`);
}
return { buffer, checksum };
};
const assertReleaseManifest = (repositoryManifest: ExtensionManifest, releaseManifest: ExtensionManifest) => {
if (repositoryManifest.id !== releaseManifest.id) throw new Error("Release manifest plugin id does not match the repository manifest.");
if (repositoryManifest.version !== releaseManifest.version) throw new Error("Release manifest version does not match the repository manifest.");
if (repositoryManifest.type !== releaseManifest.type) throw new Error("Release manifest type does not match the repository manifest.");
};
const assertBundledEntry = (manifest: PluginManifest) => {
const entryPath = manifest.entry.replace(/^\.\//, "");
if (entryPath !== "main.js") throw new Error("GitHub plugins must use ./main.js as the bundled entry.");
};
export const downloadGithubExtension = async (input: string, request: typeof fetch = window.fetch.bind(window)): Promise<GithubDownloadedExtension> => {
const coordinates = parseGithubRepositoryUrl(input);
if (!coordinates) throw new Error("Enter a public GitHub repository URL such as https://github.com/owner/repository.");
const repository = await requestJson<GithubRepositoryResponse>(request, `https://api.github.com/repos/${coordinates.owner}/${coordinates.repository}`);
if (!repository.default_branch) throw new Error("GitHub did not return a default branch for this repository.");
const manifestUrl = `https://raw.githubusercontent.com/${coordinates.owner}/${coordinates.repository}/${encodeURIComponent(repository.default_branch)}/manifest.json`;
const manifestResponse = await request(manifestUrl, { cache: "no-store", credentials: "omit" });
if (!manifestResponse.ok) throw new Error(`Repository manifest request failed with HTTP ${manifestResponse.status}.`);
const repositoryManifestText = await manifestResponse.text();
const repositoryManifest = parseExtensionManifest(JSON.parse(repositoryManifestText) as unknown);
if (repositoryManifest.type === "theme") {
return {
manifest: repositoryManifest,
manifestUrl,
repositoryUrl: repository.html_url ?? coordinates.repositoryUrl,
releaseTag: null,
pluginPackage: null,
checksums: { manifestJson: await sha256Hex(repositoryManifestText) },
};
}
assertBundledEntry(repositoryManifest);
const release = await findRelease(request, coordinates, repositoryManifest.version);
const manifestAsset = requireAsset(release, "manifest.json");
const mainAsset = requireAsset(release, "main.js");
const stylesAsset = release.assets.find((asset) => asset.name === "styles.css") ?? null;
const [downloadedManifest, downloadedMain, downloadedStyles] = await Promise.all([
downloadAsset(request, manifestAsset, 256 * 1024),
downloadAsset(request, mainAsset, MAX_MAIN_JS_BYTES),
stylesAsset ? downloadAsset(request, stylesAsset, MAX_STYLES_CSS_BYTES) : Promise.resolve(null),
]);
const releaseManifest = parseExtensionManifest(JSON.parse(new TextDecoder().decode(downloadedManifest.buffer)) as unknown);
assertReleaseManifest(repositoryManifest, releaseManifest);
if (releaseManifest.type !== "plugin") throw new Error("GitHub Release must contain a plugin manifest.");
assertBundledEntry(releaseManifest);
return {
manifest: releaseManifest,
manifestUrl: manifestAsset.browser_download_url,
repositoryUrl: repository.html_url ?? coordinates.repositoryUrl,
releaseTag: release.tag_name,
pluginPackage: {
pluginId: releaseManifest.id,
version: releaseManifest.version,
mainJs: new TextDecoder().decode(downloadedMain.buffer),
stylesCss: downloadedStyles ? new TextDecoder().decode(downloadedStyles.buffer) : null,
checksums: {
manifestJson: downloadedManifest.checksum,
mainJs: downloadedMain.checksum,
...(downloadedStyles ? { stylesCss: downloadedStyles.checksum } : {}),
},
cachedAt: new Date().toISOString(),
},
checksums: {
manifestJson: downloadedManifest.checksum,
mainJs: downloadedMain.checksum,
...(downloadedStyles ? { stylesCss: downloadedStyles.checksum } : {}),
},
};
};
@@ -0,0 +1,48 @@
export default {
activate(context) {
const disposeHello = context.commands.register({
id: "hello",
title: "Hello",
run() {
context.ui.showNotice("hello from plugin");
},
});
const disposeRead = context.commands.register({
id: "read-without-permission",
title: "Read without permission",
async run() {
await context.notes.query();
},
});
const disposeEditor = context.commands.register({
id: "replace-selection",
title: "Replace selection",
async run() {
const selection = await context.editor.getSelection();
await context.editor.replaceSelection(selection.text.toUpperCase());
},
});
const disposeSecret = context.commands.register({
id: "write-secret",
title: "Write secret",
async run() {
await context.secrets.set("token", "secret-value");
},
});
const disposePanel = context.ui.panels.register({
id: "fixture",
title: "Fixture panel",
mount(container) {
container.mountedByFixture = true;
return () => { container.mountedByFixture = false; };
},
});
return () => {
disposeHello();
disposeRead();
disposeEditor();
disposeSecret();
disposePanel();
};
},
};
@@ -0,0 +1,176 @@
import { beforeEach, describe, expect, test } from "bun:test";
const values = new Map();
const styles = new Map();
const eventListeners = new Map();
globalThis.window = {
location: { href: "https://edgeever.example/settings" },
localStorage: {
getItem: (key) => values.get(key) ?? null,
setItem: (key, value) => values.set(key, String(value)),
removeItem: (key) => values.delete(key),
},
addEventListener: (name, listener) => eventListeners.set(name, listener),
removeEventListener: (name) => eventListeners.delete(name),
};
globalThis.document = {
documentElement: {
classList: { contains: () => false },
dataset: {},
style: {
setProperty: (key, value) => styles.set(key, value),
removeProperty: (key) => styles.delete(key),
},
removeAttribute: (name) => {
if (name === "data-edgeever-extension-theme") delete globalThis.document.documentElement.dataset.edgeeverExtensionTheme;
},
},
};
globalThis.MutationObserver = class {
observe() {}
disconnect() {}
};
const { EdgeEverPluginHost } = await import("./plugin-host.ts");
const { sha256Hex } = await import("./github-plugin-distribution.ts");
const repository = {
listMemos: async () => ({ memos: [], totalCount: 0, nextCursor: null }),
};
beforeEach(() => {
values.clear();
styles.clear();
eventListeners.clear();
delete globalThis.window.fetch;
globalThis.document.documentElement.dataset = {};
});
describe("EdgeEverPluginHost", () => {
test("applies a validated code-free theme", async () => {
const host = new EdgeEverPluginHost({ repository, scope: "test" });
host.installManifest({
type: "theme",
id: "org.edgeever.test-theme",
name: "Test theme",
version: "1.0.0",
themeApiVersion: "1",
modes: ["light"],
light: {
"color.background": "#010203",
"color.accent": "#16a06e",
},
}, "https://example.com/theme/manifest.json");
await host.setEnabled("org.edgeever.test-theme", true);
expect(styles.get("--edgeever-theme-background")).toBe("#010203");
expect(styles.get("--edgeever-theme-accent")).toBe("#16a06e");
expect(globalThis.document.documentElement.dataset.edgeeverExtensionTheme).toBe("org.edgeever.test-theme");
await host.dispose();
});
test("loads a plugin and runs its registered command", async () => {
const notices = [];
const secrets = new Map();
const secretStorage = {
get: async (pluginId, key) => secrets.get(`${pluginId}:${key}`) ?? null,
set: async (pluginId, key, value) => secrets.set(`${pluginId}:${key}`, value),
remove: async (pluginId, key) => secrets.delete(`${pluginId}:${key}`),
};
const host = new EdgeEverPluginHost({ repository, scope: "test", onNotice: (message) => notices.push(message), secretStorage });
let replacement = null;
host.setEditorAdapter({
getSelection: () => ({ noteId: "note-1", from: 1, to: 6, empty: false, text: "hello", contentMarkdown: "hello" }),
replaceSelection: (value) => { replacement = value; },
insertAtCursor: () => undefined,
});
const entry = new URL("./plugin-host.fixture.mjs", import.meta.url).href;
host.installManifest({
type: "plugin",
id: "org.edgeever.test-plugin",
name: "Test plugin",
version: "1.0.0",
apiVersion: "1",
entry,
permissions: ["ui:commands", "ui:notices", "ui:panels", "editor:read", "editor:write", "secrets"],
}, "https://example.com/plugin/manifest.json");
await host.setEnabled("org.edgeever.test-plugin", true);
await host.runCommand("org.edgeever.test-plugin", "hello");
expect(host.getSnapshot().commands).toHaveLength(4);
expect(host.getSnapshot().panels).toHaveLength(1);
expect(notices).toEqual(["hello from plugin"]);
expect(host.getSnapshot().recentActions[0]).toMatchObject({ id: "hello", type: "command" });
await expect(host.runCommand("org.edgeever.test-plugin", "read-without-permission")).rejects.toThrow("notes:read");
await host.runCommand("org.edgeever.test-plugin", "replace-selection");
expect(replacement).toBe("HELLO");
await host.runCommand("org.edgeever.test-plugin", "write-secret");
expect(secrets.get("test:org.edgeever.test-plugin:token")).toBe("secret-value");
const container = {};
const disposePanel = await host.mountPanel("org.edgeever.test-plugin", "fixture", container);
expect(container.mountedByFixture).toBe(true);
expect(host.getSnapshot().recentActions[0]).toMatchObject({ id: "fixture", type: "panel" });
disposePanel();
expect(container.mountedByFixture).toBe(false);
const mountedDuringDisable = {};
await host.mountPanel("org.edgeever.test-plugin", "fixture", mountedDuringDisable);
await host.setEnabled("org.edgeever.test-plugin", false);
expect(mountedDuringDisable.mountedByFixture).toBe(false);
expect(host.getSnapshot().panels).toHaveLength(0);
expect(host.getSnapshot().recentActions).toHaveLength(0);
await host.dispose();
});
test("installs a checksum-pinned marketplace package and removes its cache on uninstall", async () => {
const manifest = {
type: "plugin",
id: "org.edgeever.marketplace-test",
name: "Marketplace Test",
version: "1.0.0",
apiVersion: "1",
entry: "./main.js",
permissions: [],
};
const manifestText = JSON.stringify(manifest);
const mainJs = "export default { activate() {} };";
globalThis.window.fetch = async (input) => {
const url = String(input);
if (url.endsWith("/manifest.json")) return new Response(manifestText, { headers: { "content-type": "application/json" } });
if (url.endsWith("/main.js")) return new Response(mainJs, { headers: { "content-type": "text/javascript" } });
return new Response(null, { status: 404 });
};
const packages = new Map();
const packageStorage = {
get: async (pluginId, version) => packages.get(pluginId)?.version === version ? packages.get(pluginId) : null,
put: async (value) => packages.set(value.pluginId, value),
remove: async (pluginId) => packages.delete(pluginId),
};
const host = new EdgeEverPluginHost({ repository, scope: "test", packageStorage });
const entry = {
id: manifest.id,
name: manifest.name,
description: "Verified test plugin",
author: "EdgeEver",
category: "Testing",
repositoryUrl: "https://github.com/edgeever/marketplace-test",
distribution: { type: "manifest", manifestUrl: "https://plugins.example/manifest.json" },
verification: {
version: manifest.version,
checksums: { manifestJson: await sha256Hex(manifestText), mainJs: await sha256Hex(mainJs) },
},
};
await host.installMarketplaceEntry(entry);
expect(host.getSnapshot().extensions[0].source).toMatchObject({ kind: "marketplace", verified: true });
expect(packages.get(manifest.id)?.checksums.mainJs).toBe(entry.verification.checksums.mainJs);
await host.uninstall(manifest.id);
expect(packages.has(manifest.id)).toBe(false);
await host.dispose();
});
});
+844
View File
@@ -0,0 +1,844 @@
import {
parseExtensionManifest,
type MarketplaceEntry,
type EdgeEverPlugin,
type ExtensionManifest,
type PluginCommand,
type PluginContext,
type PluginEventMap,
type PluginManifest,
type PluginNote,
type PluginNoteSummary,
type PluginPanel,
type PluginPermission,
type PluginEditorSelection,
type ThemeManifest,
type ThemeTokenName,
type ThemeTokens,
} from "@edgeever/plugin-api";
import { markdownToDoc } from "@edgeever/shared";
import type { EdgeEverRepository } from "@/lib/repository";
import { WebPluginSecretStore, type PluginSecretStorage } from "@/lib/plugins/plugin-secret-store";
import { WebPluginPackageStore, type CachedPluginPackage, type PluginPackageStorage } from "@/lib/plugins/plugin-package-store";
import { downloadGithubExtension, parseGithubRepositoryUrl, sha256Hex } from "@/lib/plugins/github-plugin-distribution";
const INSTALLED_EXTENSIONS_STORAGE_KEY = "edgeever.extensions.installed.v1";
const ACTIVE_THEME_STORAGE_KEY = "edgeever.extensions.active-theme.v1";
const STORAGE_PREFIX = "edgeever.plugin-data.v1";
const RECENT_ACTIONS_STORAGE_PREFIX = "edgeever.extensions.recent-actions.v1";
const readStorageItem = (key: string) => {
try {
return window.localStorage.getItem(key);
} catch {
return null;
}
};
const writeStorageItem = (key: string, value: string) => {
try {
window.localStorage.setItem(key, value);
} catch {
throw new Error("Browser storage is unavailable or full.");
}
};
const removeStorageItem = (key: string) => {
try {
window.localStorage.removeItem(key);
} catch {
throw new Error("Browser storage is unavailable.");
}
};
const THEME_TOKEN_CSS_VARIABLES: Record<ThemeTokenName, string> = {
"color.background": "--edgeever-theme-background",
"color.surface": "--edgeever-theme-surface",
"color.surfaceMuted": "--edgeever-theme-surface-muted",
"color.text": "--edgeever-theme-text",
"color.textMuted": "--edgeever-theme-text-muted",
"color.border": "--edgeever-theme-border",
"color.accent": "--edgeever-theme-accent",
"color.accentForeground": "--edgeever-theme-accent-foreground",
"color.success": "--edgeever-theme-success",
"color.warning": "--edgeever-theme-warning",
"color.danger": "--edgeever-theme-danger",
"font.body": "--edgeever-theme-font-body",
"font.mono": "--edgeever-theme-font-mono",
"font.size": "--edgeever-theme-font-size",
"lineHeight.body": "--edgeever-theme-line-height",
"radius.medium": "--edgeever-theme-radius",
"density.scale": "--edgeever-theme-density",
"editor.contentWidth": "--editor-content-max-width",
};
export interface InstalledExtension {
manifestUrl: string;
manifest: ExtensionManifest;
enabled: boolean;
installedAt: string;
error: string | null;
source: ExtensionInstallSource;
}
export interface ExtensionInstallSource {
kind: "manifest" | "github" | "marketplace";
repositoryUrl?: string;
releaseTag?: string;
verified: boolean;
}
export interface RegisteredPluginCommand {
pluginId: string;
id: string;
title: string;
}
export interface RegisteredPluginPanel {
pluginId: string;
id: string;
title: string;
}
export interface RegisteredPluginAction {
pluginId: string;
id: string;
title: string;
type: "command" | "panel";
}
export interface PluginEditorAdapter {
getSelection(): PluginEditorSelection | null;
replaceSelection(contentMarkdown: string): void;
insertAtCursor(contentMarkdown: string): void;
}
export interface PluginHostSnapshot {
extensions: InstalledExtension[];
commands: RegisteredPluginCommand[];
panels: RegisteredPluginPanel[];
recentActions: RegisteredPluginAction[];
activeThemeId: string | null;
}
interface PluginHostOptions {
repository: EdgeEverRepository;
scope: string;
onWorkspaceChanged?: () => void | Promise<void>;
onNotice?: (message: string) => void;
secretStorage?: PluginSecretStorage;
packageStorage?: PluginPackageStorage;
}
interface ActivePlugin {
plugin: EdgeEverPlugin;
disposers: Array<() => void>;
}
const toPluginNoteSummary = (note: Awaited<ReturnType<EdgeEverRepository["listMemos"]>>["memos"][number]): PluginNoteSummary => ({
id: note.id,
notebookId: note.notebookId,
title: note.title,
excerpt: note.excerpt,
tags: [...note.tags],
isPinned: note.isPinned,
createdAt: note.createdAt,
updatedAt: note.updatedAt,
});
const toPluginNote = (note: Awaited<ReturnType<EdgeEverRepository["getMemo"]>>["memo"]): PluginNote => ({
...toPluginNoteSummary(note),
contentMarkdown: note.contentMarkdown,
contentText: note.contentText,
});
const getErrorMessage = (error: unknown) => error instanceof Error ? error.message : String(error);
const normalizeInstallSource = (value: unknown): ExtensionInstallSource => {
if (!value || typeof value !== "object") return { kind: "manifest", verified: false };
const candidate = value as Partial<ExtensionInstallSource>;
if (candidate.kind !== "manifest" && candidate.kind !== "github" && candidate.kind !== "marketplace") {
return { kind: "manifest", verified: false };
}
return {
kind: candidate.kind,
verified: candidate.kind === "marketplace" && candidate.verified === true,
...(typeof candidate.repositoryUrl === "string" ? { repositoryUrl: candidate.repositoryUrl } : {}),
...(typeof candidate.releaseTag === "string" ? { releaseTag: candidate.releaseTag } : {}),
};
};
const readInstalledExtensions = (): InstalledExtension[] => {
try {
const parsed = JSON.parse(readStorageItem(INSTALLED_EXTENSIONS_STORAGE_KEY) ?? "[]") as unknown;
if (!Array.isArray(parsed)) return [];
return parsed.flatMap((item) => {
try {
if (!item || typeof item !== "object") return [];
const candidate = item as Partial<InstalledExtension>;
if (typeof candidate.manifestUrl !== "string") return [];
return [{
manifestUrl: candidate.manifestUrl,
manifest: parseExtensionManifest(candidate.manifest),
enabled: Boolean(candidate.enabled),
installedAt: typeof candidate.installedAt === "string" ? candidate.installedAt : new Date().toISOString(),
error: typeof candidate.error === "string" ? candidate.error : null,
source: normalizeInstallSource(candidate.source),
}];
} catch {
return [];
}
});
} catch {
return [];
}
};
const assertPermission = (manifest: PluginManifest, permission: PluginPermission) => {
if (!manifest.permissions.includes(permission)) {
throw new Error(`${manifest.name} has not declared the ${permission} permission.`);
}
};
const isAllowedNetworkHost = (hostname: string, allowedHosts: string[]) =>
allowedHosts.some((allowedHost) => {
const normalized = allowedHost.trim().toLocaleLowerCase();
if (normalized.startsWith("*.")) {
const suffix = normalized.slice(1);
return hostname.endsWith(suffix) && hostname !== suffix.slice(1);
}
return hostname === normalized;
});
const resolveManifestEntry = (manifestUrl: string, entry: string) => new URL(entry, manifestUrl).href;
export class EdgeEverPluginHost {
private readonly repository: EdgeEverRepository;
private readonly scope: string;
private readonly onWorkspaceChanged?: () => void | Promise<void>;
private readonly onNotice?: (message: string) => void;
private readonly secretStorage: PluginSecretStorage;
private readonly packageStorage: PluginPackageStorage;
private readonly listeners = new Set<() => void>();
private readonly activePlugins = new Map<string, ActivePlugin>();
private readonly commands = new Map<string, PluginCommand & { pluginId: string }>();
private readonly panels = new Map<string, PluginPanel & { pluginId: string }>();
private readonly mountedPanels = new Map<string, Set<() => void>>();
private readonly eventListeners = new Map<keyof PluginEventMap, Set<(payload: never) => void>>();
private extensions = readInstalledExtensions();
private activeThemeId = readStorageItem(ACTIVE_THEME_STORAGE_KEY);
private snapshot: PluginHostSnapshot = { extensions: [], commands: [], panels: [], recentActions: [], activeThemeId: null };
private recentActions: RegisteredPluginAction[];
private editorAdapter: PluginEditorAdapter | null = null;
private themeObserver: MutationObserver | null = null;
private started = false;
constructor(options: PluginHostOptions) {
this.repository = options.repository;
this.scope = options.scope;
this.onWorkspaceChanged = options.onWorkspaceChanged;
this.onNotice = options.onNotice;
this.secretStorage = options.secretStorage ?? new WebPluginSecretStore();
this.packageStorage = options.packageStorage ?? new WebPluginPackageStore();
this.recentActions = this.readRecentActions();
this.refreshSnapshot();
}
readonly subscribe = (listener: () => void) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
readonly getSnapshot = () => this.snapshot;
setEditorAdapter(adapter: PluginEditorAdapter | null) {
this.editorAdapter = adapter;
return () => {
if (this.editorAdapter === adapter) this.editorAdapter = null;
};
}
async activateEnabled() {
this.start();
for (const extension of this.extensions) {
if (!extension.enabled || extension.manifest.type !== "plugin") continue;
await this.activatePlugin(extension.manifest.id).catch(() => undefined);
}
this.applyActiveTheme();
}
async installFromSource(input: string) {
if (parseGithubRepositoryUrl(input)) return this.installFromGithubRepository(input);
return this.installFromManifestUrl(input);
}
async installFromGithubRepository(input: string, marketplaceEntry?: MarketplaceEntry) {
const downloaded = await downloadGithubExtension(input);
if (marketplaceEntry) this.assertMarketplaceDownload(marketplaceEntry, downloaded.manifest, downloaded.checksums);
if (downloaded.pluginPackage) await this.packageStorage.put(downloaded.pluginPackage);
const wasActive = this.activePlugins.has(downloaded.manifest.id);
if (wasActive) await this.deactivatePlugin(downloaded.manifest.id);
const installed = this.installManifest(downloaded.manifest, downloaded.manifestUrl, {
kind: marketplaceEntry ? "marketplace" : "github",
verified: Boolean(marketplaceEntry),
repositoryUrl: downloaded.repositoryUrl,
...(downloaded.releaseTag ? { releaseTag: downloaded.releaseTag } : {}),
});
if (installed.enabled && installed.manifest.type === "plugin") await this.activatePlugin(installed.manifest.id);
return installed;
}
async installMarketplaceEntry(entry: MarketplaceEntry) {
if (entry.distribution.type === "github") {
return this.installFromGithubRepository(entry.distribution.repositoryUrl, entry);
}
return this.installFromManifestUrl(entry.distribution.manifestUrl, entry);
}
async installFromManifestUrl(input: string, marketplaceEntry?: MarketplaceEntry) {
const manifestUrl = new URL(input, window.location.href);
if (!["http:", "https:"].includes(manifestUrl.protocol)) {
throw new Error("Extension manifests must use an HTTP or HTTPS URL.");
}
const response = await window.fetch(manifestUrl.href, { cache: "no-store", credentials: "omit" });
if (!response.ok) throw new Error(`Manifest request failed with HTTP ${response.status}.`);
const manifestText = await response.text();
const manifest = parseExtensionManifest(JSON.parse(manifestText) as unknown);
let pluginPackage: CachedPluginPackage | null = null;
if (manifest.type === "plugin") {
const entryUrl = resolveManifestEntry(manifestUrl.href, manifest.entry);
const entryResponse = await window.fetch(entryUrl, { cache: "no-store", credentials: "omit" });
if (!entryResponse.ok) throw new Error(`Plugin entry request failed with HTTP ${entryResponse.status}.`);
const mainJs = await entryResponse.text();
if (new TextEncoder().encode(mainJs).byteLength > 5 * 1024 * 1024) throw new Error("Plugin main.js exceeds the 5 MB limit.");
const stylesUrl = new URL("./styles.css", manifestUrl.href);
const stylesResponse = await window.fetch(stylesUrl, { cache: "no-store", credentials: "omit" });
const stylesCss = stylesResponse.ok && stylesResponse.headers.get("content-type")?.toLocaleLowerCase().includes("text/css")
? await stylesResponse.text()
: null;
if (stylesCss && new TextEncoder().encode(stylesCss).byteLength > 1024 * 1024) throw new Error("Plugin styles.css exceeds the 1 MB limit.");
pluginPackage = {
pluginId: manifest.id,
version: manifest.version,
mainJs,
stylesCss,
checksums: {
manifestJson: await sha256Hex(manifestText),
mainJs: await sha256Hex(mainJs),
...(stylesCss ? { stylesCss: await sha256Hex(stylesCss) } : {}),
},
cachedAt: new Date().toISOString(),
};
}
const actualChecksums = pluginPackage?.checksums ?? { manifestJson: await sha256Hex(manifestText) };
if (marketplaceEntry) this.assertMarketplaceDownload(marketplaceEntry, manifest, actualChecksums);
if (pluginPackage) await this.packageStorage.put(pluginPackage);
const wasActive = this.activePlugins.has(manifest.id);
if (wasActive) await this.deactivatePlugin(manifest.id);
const installed = this.installManifest(manifest, manifestUrl.href, {
kind: marketplaceEntry ? "marketplace" : "manifest",
verified: Boolean(marketplaceEntry),
repositoryUrl: marketplaceEntry?.repositoryUrl,
});
if (installed.enabled && installed.manifest.type === "plugin") await this.activatePlugin(installed.manifest.id);
return installed;
}
installManifest(manifest: ExtensionManifest, manifestUrl: string, source: ExtensionInstallSource = { kind: "manifest", verified: false }) {
const normalizedManifest = parseExtensionManifest(manifest);
const existing = this.extensions.find((item) => item.manifest.id === normalizedManifest.id);
const installed: InstalledExtension = {
manifestUrl,
manifest: normalizedManifest,
enabled: existing?.enabled ?? false,
installedAt: existing?.installedAt ?? new Date().toISOString(),
error: null,
source,
};
this.extensions = [...this.extensions.filter((item) => item.manifest.id !== normalizedManifest.id), installed]
.sort((left, right) => left.manifest.name.localeCompare(right.manifest.name));
this.persist();
return installed;
}
async setEnabled(extensionId: string, enabled: boolean) {
const extension = this.requireExtension(extensionId);
if (extension.manifest.type === "theme") {
this.extensions = this.extensions.map((item) => {
if (item.manifest.type !== "theme") return item;
if (enabled) return { ...item, enabled: item.manifest.id === extensionId, error: item.manifest.id === extensionId ? null : item.error };
return item.manifest.id === extensionId ? { ...item, enabled: false, error: null } : item;
});
if (enabled) {
this.activeThemeId = extensionId;
writeStorageItem(ACTIVE_THEME_STORAGE_KEY, extensionId);
} else if (this.activeThemeId === extensionId) {
this.activeThemeId = null;
removeStorageItem(ACTIVE_THEME_STORAGE_KEY);
}
this.applyActiveTheme();
this.persist();
return;
}
if (enabled) {
this.extensions = this.extensions.map((item) => item.manifest.id === extensionId ? { ...item, enabled: true, error: null } : item);
this.persist();
await this.activatePlugin(extensionId);
return;
}
await this.deactivatePlugin(extensionId);
this.extensions = this.extensions.map((item) => item.manifest.id === extensionId ? { ...item, enabled: false, error: null } : item);
this.persist();
}
async uninstall(extensionId: string) {
const extension = this.requireExtension(extensionId);
if (extension.manifest.type === "plugin") await this.deactivatePlugin(extensionId);
this.extensions = this.extensions.filter((item) => item.manifest.id !== extensionId);
this.recentActions = this.recentActions.filter((action) => action.pluginId !== extensionId);
this.persistRecentActions();
if (this.activeThemeId === extensionId) {
this.activeThemeId = null;
removeStorageItem(ACTIVE_THEME_STORAGE_KEY);
this.applyActiveTheme();
}
this.persist();
await this.packageStorage.remove(extensionId).catch(() => undefined);
}
async runCommand(pluginId: string, commandId: string) {
const command = this.commands.get(`${pluginId}:${commandId}`);
if (!command) throw new Error("Plugin command is not registered.");
await command.run();
this.recordRecentAction({ pluginId, id: commandId, title: command.title, type: "command" });
}
async mountPanel(pluginId: string, panelId: string, container: HTMLElement) {
const key = `${pluginId}:${panelId}`;
const panel = this.panels.get(key);
if (!panel) throw new Error("Plugin panel is not registered.");
const mounted = this.mountedPanels.get(key) ?? new Set<() => void>();
this.mountedPanels.set(key, mounted);
const pluginDispose = await panel.mount(container);
if (this.panels.get(key) !== panel) {
if (typeof pluginDispose === "function") pluginDispose();
throw new Error("Plugin panel was closed while mounting.");
}
let active = true;
const dispose = () => {
if (!active) return;
active = false;
mounted.delete(dispose);
if (mounted.size === 0) this.mountedPanels.delete(key);
if (typeof pluginDispose === "function") pluginDispose();
};
mounted.add(dispose);
this.recordRecentAction({ pluginId, id: panelId, title: panel.title, type: "panel" });
return dispose;
}
async dispose() {
this.started = false;
window.removeEventListener("edgeever:sync-queue-changed", this.handleSyncQueueChanged);
this.themeObserver?.disconnect();
this.themeObserver = null;
for (const pluginId of [...this.activePlugins.keys()]) await this.deactivatePlugin(pluginId);
}
private start() {
if (this.started) return;
this.started = true;
this.themeObserver = new MutationObserver(() => this.applyActiveTheme());
this.themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
window.addEventListener("edgeever:sync-queue-changed", this.handleSyncQueueChanged);
this.applyActiveTheme();
}
private readonly handleSyncQueueChanged = () => {
this.emit("workspace.sync-queue-changed", {});
};
private requireExtension(extensionId: string) {
const extension = this.extensions.find((item) => item.manifest.id === extensionId);
if (!extension) throw new Error("Extension is not installed.");
return extension;
}
private async activatePlugin(pluginId: string) {
if (this.activePlugins.has(pluginId)) return;
const extension = this.requireExtension(pluginId);
if (extension.manifest.type !== "plugin") return;
const disposers: Array<() => void> = [];
try {
const platform = window.edgeeverDesktop?.isAvailable ? "desktop" : "web";
if (extension.manifest.platforms?.length && !extension.manifest.platforms.includes(platform)) {
throw new Error(`${extension.manifest.name} does not support the ${platform} platform.`);
}
const cachedPackage = await this.packageStorage.get(extension.manifest.id, extension.manifest.version).catch(() => null);
let module: { default?: EdgeEverPlugin | { default?: EdgeEverPlugin } };
if (cachedPackage) {
const entryUrl = URL.createObjectURL(new Blob([cachedPackage.mainJs], { type: "text/javascript" }));
try {
module = await import(/* @vite-ignore */ entryUrl) as { default?: EdgeEverPlugin | { default?: EdgeEverPlugin } };
} finally {
URL.revokeObjectURL(entryUrl);
}
if (cachedPackage.stylesCss) {
const style = document.createElement("style");
style.dataset.edgeeverPluginStyle = extension.manifest.id;
style.textContent = cachedPackage.stylesCss;
document.head.appendChild(style);
disposers.push(() => style.remove());
}
} else {
const entryUrl = resolveManifestEntry(extension.manifestUrl, extension.manifest.entry);
module = await import(/* @vite-ignore */ entryUrl) as { default?: EdgeEverPlugin | { default?: EdgeEverPlugin } };
}
const candidate = module.default;
// Some development loaders wrap ESM defaults once more. Accept that
// interop shape while keeping the public contract a default export.
const unwrapped: unknown = typeof candidate === "object" && candidate !== null && "default" in candidate ? candidate.default : candidate;
if (typeof unwrapped !== "object" || unwrapped === null || typeof (unwrapped as { activate?: unknown }).activate !== "function") {
throw new Error("Plugin entry must default-export an object with activate(context).");
}
const plugin = unwrapped as EdgeEverPlugin;
const context = this.createContext(extension.manifest, disposers);
const deactivate = await plugin.activate(context);
if (typeof deactivate === "function" && !disposers.includes(deactivate)) disposers.push(deactivate);
this.activePlugins.set(pluginId, { plugin, disposers });
this.setExtensionError(pluginId, null);
} catch (error) {
for (const dispose of disposers.reverse()) dispose();
const message = getErrorMessage(error);
this.extensions = this.extensions.map((item) => item.manifest.id === pluginId ? { ...item, enabled: false, error: message } : item);
this.persist();
throw error;
}
}
private async deactivatePlugin(pluginId: string) {
const active = this.activePlugins.get(pluginId);
if (!active) return;
this.activePlugins.delete(pluginId);
for (const dispose of active.disposers.reverse()) {
try { dispose(); } catch { /* A broken disposer must not strand the plugin. */ }
}
await active.plugin.deactivate?.();
for (const key of [...this.commands.keys()]) {
if (key.startsWith(`${pluginId}:`)) this.commands.delete(key);
}
for (const key of [...this.panels.keys()]) {
if (key.startsWith(`${pluginId}:`)) this.panels.delete(key);
}
for (const [key, disposers] of [...this.mountedPanels]) {
if (!key.startsWith(`${pluginId}:`)) continue;
for (const dispose of [...disposers]) {
try { dispose(); } catch { /* Panel cleanup must not strand the plugin. */ }
}
}
this.refreshSnapshot();
}
private createContext(manifest: PluginManifest, disposers: Array<() => void>): PluginContext {
const storagePrefix = `${STORAGE_PREFIX}:${this.scope}:${manifest.id}:`;
const secretNamespace = `${this.scope}:${manifest.id}`;
return {
pluginId: manifest.id,
notes: {
query: async (input = {}) => {
assertPermission(manifest, "notes:read");
const result = await this.repository.listMemos({
notebookId: input.notebookId,
q: input.text,
sort: input.sort,
limit: Math.min(Math.max(input.limit ?? 50, 1), 200),
offset: Math.max(input.offset ?? 0, 0),
});
const notes = result.memos
.filter((note) => !input.tags?.length || input.tags.every((tag) => note.tags.includes(tag)))
.map(toPluginNoteSummary);
const offset = Math.max(input.offset ?? 0, 0);
return { notes, totalCount: result.totalCount, nextOffset: result.nextCursor ? offset + notes.length : null };
},
get: async (noteId) => {
assertPermission(manifest, "notes:read");
return toPluginNote((await this.repository.getMemo(noteId)).memo);
},
create: async (input) => {
assertPermission(manifest, "notes:write");
const note = toPluginNote((await this.repository.createMemo(input)).memo);
this.emit("note.created", { note });
await this.onWorkspaceChanged?.();
return note;
},
update: async (noteId, input) => {
assertPermission(manifest, "notes:write");
const current = (await this.repository.getMemo(noteId)).memo;
const contentMarkdown = input.contentMarkdown ?? current.contentMarkdown;
const updated = (await this.repository.updateMemo(current, {
expectedRevision: current.revision,
expectedContentHash: current.contentHash,
editSessionId: `plugin:${manifest.id}`,
title: input.title ?? current.title ?? "",
contentJson: markdownToDoc(contentMarkdown),
contentMarkdown,
tags: input.tags ?? current.tags,
})).memo;
const note = toPluginNote(updated);
this.emit("note.updated", { note });
await this.onWorkspaceChanged?.();
return note;
},
delete: async (noteId, options) => {
assertPermission(manifest, "notes:delete");
await this.repository.deleteMemo(noteId, Boolean(options?.permanent));
this.emit("note.deleted", { noteId });
await this.onWorkspaceChanged?.();
},
},
notebooks: {
list: async () => {
assertPermission(manifest, "metadata:read");
const { notebooks } = await this.repository.listNotebooks();
return notebooks.map(({ id, parentId, name, memoCount }) => ({ id, parentId, name, memoCount }));
},
},
tags: {
list: async () => {
assertPermission(manifest, "metadata:read");
const { tags } = await this.repository.listTags();
return tags.map(({ name, memoCount }) => ({ name, noteCount: memoCount }));
},
rename: async (name, nextName) => {
assertPermission(manifest, "metadata:write");
const { updated } = await this.repository.renameTag(name, nextName);
this.emit("tag.changed", { previousName: name, name: nextName });
await this.onWorkspaceChanged?.();
return updated;
},
delete: async (name) => {
assertPermission(manifest, "metadata:write");
const { updated } = await this.repository.deleteTag(name);
this.emit("tag.changed", { previousName: name, deleted: true });
await this.onWorkspaceChanged?.();
return updated;
},
},
commands: {
register: (command) => {
assertPermission(manifest, "ui:commands");
if (!/^[a-z0-9][a-z0-9._-]*$/i.test(command.id)) throw new Error("Plugin command id is invalid.");
const key = `${manifest.id}:${command.id}`;
if (this.commands.has(key)) throw new Error(`Plugin command already exists: ${command.id}`);
this.commands.set(key, { ...command, pluginId: manifest.id });
this.refreshSnapshot();
const dispose = () => {
this.commands.delete(key);
this.refreshSnapshot();
};
disposers.push(dispose);
return dispose;
},
},
events: {
on: (event, listener) => {
const listeners = this.eventListeners.get(event) ?? new Set();
listeners.add(listener as (payload: never) => void);
this.eventListeners.set(event, listeners);
const dispose = () => listeners.delete(listener as (payload: never) => void);
disposers.push(dispose);
return dispose;
},
},
storage: {
get: async <T>(key: string) => {
assertPermission(manifest, "storage");
const stored = readStorageItem(`${storagePrefix}${key}`);
return stored === null ? null : JSON.parse(stored) as T;
},
set: async <T>(key: string, value: T) => {
assertPermission(manifest, "storage");
writeStorageItem(`${storagePrefix}${key}`, JSON.stringify(value));
},
remove: async (key: string) => {
assertPermission(manifest, "storage");
removeStorageItem(`${storagePrefix}${key}`);
},
},
secrets: {
get: async (key) => {
assertPermission(manifest, "secrets");
return this.secretStorage.get(secretNamespace, key);
},
set: async (key, value) => {
assertPermission(manifest, "secrets");
await this.secretStorage.set(secretNamespace, key, value);
},
remove: async (key) => {
assertPermission(manifest, "secrets");
await this.secretStorage.remove(secretNamespace, key);
},
},
editor: {
getSelection: async () => {
assertPermission(manifest, "editor:read");
return this.editorAdapter?.getSelection() ?? null;
},
replaceSelection: async (contentMarkdown) => {
assertPermission(manifest, "editor:write");
if (!this.editorAdapter) throw new Error("No note editor is currently active.");
this.editorAdapter.replaceSelection(contentMarkdown);
},
insertAtCursor: async (contentMarkdown) => {
assertPermission(manifest, "editor:write");
if (!this.editorAdapter) throw new Error("No note editor is currently active.");
this.editorAdapter.insertAtCursor(contentMarkdown);
},
},
network: {
fetch: async (input, init) => {
assertPermission(manifest, "network");
const url = new URL(input);
if (url.protocol !== "https:" && !(url.protocol === "http:" && ["localhost", "127.0.0.1"].includes(url.hostname))) {
throw new Error("Plugin network requests must use HTTPS, except for localhost development.");
}
if (!manifest.networkHosts?.length || !isAllowedNetworkHost(url.hostname.toLocaleLowerCase(), manifest.networkHosts)) {
throw new Error(`${url.hostname} is not declared in this plugin's networkHosts.`);
}
return window.fetch(url, { ...init, credentials: "omit" });
},
},
ui: {
showNotice: (message) => {
assertPermission(manifest, "ui:notices");
this.onNotice?.(message);
},
panels: {
register: (panel) => {
assertPermission(manifest, "ui:panels");
if (!/^[a-z0-9][a-z0-9._-]*$/i.test(panel.id)) throw new Error("Plugin panel id is invalid.");
if (!panel.title.trim()) throw new Error("Plugin panel title is required.");
const key = `${manifest.id}:${panel.id}`;
if (this.panels.has(key)) throw new Error(`Plugin panel already exists: ${panel.id}`);
this.panels.set(key, { ...panel, pluginId: manifest.id });
this.refreshSnapshot();
const dispose = () => {
this.panels.delete(key);
this.refreshSnapshot();
};
disposers.push(dispose);
return dispose;
},
},
},
};
}
private assertMarketplaceDownload(
entry: MarketplaceEntry,
manifest: ExtensionManifest,
actualChecksums: Partial<CachedPluginPackage["checksums"]>
) {
if (manifest.id !== entry.id) throw new Error("Marketplace plugin id does not match the downloaded manifest.");
if (manifest.version !== entry.verification.version) throw new Error("Downloaded version does not match the marketplace verified version.");
for (const [name, expected] of Object.entries(entry.verification.checksums ?? {})) {
const actual = actualChecksums[name as keyof CachedPluginPackage["checksums"]];
if (!actual || actual.toLocaleLowerCase() !== expected) throw new Error(`${name} does not match the marketplace verified checksum.`);
}
}
private emit<K extends keyof PluginEventMap>(event: K, payload: PluginEventMap[K]) {
for (const listener of this.eventListeners.get(event) ?? []) {
try { listener(payload as never); } catch (error) { console.error(`Plugin event listener failed for ${event}`, error); }
}
}
private applyActiveTheme() {
const root = document.documentElement;
for (const cssVariable of Object.values(THEME_TOKEN_CSS_VARIABLES)) root.style.removeProperty(cssVariable);
root.removeAttribute("data-edgeever-extension-theme");
if (!this.activeThemeId) return;
const extension = this.extensions.find((item) => item.enabled && item.manifest.id === this.activeThemeId);
if (!extension || extension.manifest.type !== "theme") return;
const theme = extension.manifest as ThemeManifest;
const tokens: ThemeTokens = root.classList.contains("dark") && theme.dark ? theme.dark : theme.light;
for (const [token, value] of Object.entries(tokens)) {
root.style.setProperty(THEME_TOKEN_CSS_VARIABLES[token as ThemeTokenName], value);
}
root.dataset.edgeeverExtensionTheme = theme.id;
}
private setExtensionError(extensionId: string, error: string | null) {
this.extensions = this.extensions.map((item) => item.manifest.id === extensionId ? { ...item, error } : item);
this.persist();
}
private persist() {
writeStorageItem(INSTALLED_EXTENSIONS_STORAGE_KEY, JSON.stringify(this.extensions));
this.refreshSnapshot();
}
private readRecentActions(): RegisteredPluginAction[] {
try {
const parsed = JSON.parse(readStorageItem(`${RECENT_ACTIONS_STORAGE_PREFIX}:${this.scope}`) ?? "[]") as unknown;
if (!Array.isArray(parsed)) return [];
return parsed.flatMap((item) => {
if (!item || typeof item !== "object") return [];
const action = item as Partial<RegisteredPluginAction>;
if (
typeof action.pluginId !== "string" ||
typeof action.id !== "string" ||
typeof action.title !== "string" ||
(action.type !== "command" && action.type !== "panel")
) return [];
return [action as RegisteredPluginAction];
}).slice(0, 5);
} catch {
return [];
}
}
private recordRecentAction(action: RegisteredPluginAction) {
this.recentActions = [
action,
...this.recentActions.filter((item) => item.pluginId !== action.pluginId || item.id !== action.id || item.type !== action.type),
].slice(0, 5);
this.persistRecentActions();
this.refreshSnapshot();
}
private persistRecentActions() {
try {
writeStorageItem(`${RECENT_ACTIONS_STORAGE_PREFIX}:${this.scope}`, JSON.stringify(this.recentActions));
} catch {
// Recent actions are a convenience and must never make a successful plugin action fail.
}
}
private refreshSnapshot() {
const registeredActions = new Map<string, RegisteredPluginAction>([
...[...this.commands.values()].map(({ pluginId, id, title }) => [
`command:${pluginId}:${id}`,
{ pluginId, id, title, type: "command" as const },
] as const),
...[...this.panels.values()].map(({ pluginId, id, title }) => [
`panel:${pluginId}:${id}`,
{ pluginId, id, title, type: "panel" as const },
] as const),
]);
this.snapshot = {
extensions: this.extensions.map((item) => ({ ...item, manifest: { ...item.manifest } })),
commands: [...this.commands.values()].map(({ pluginId, id, title }) => ({ pluginId, id, title })),
panels: [...this.panels.values()].map(({ pluginId, id, title }) => ({ pluginId, id, title })),
recentActions: this.recentActions.flatMap((action) => {
const registered = registeredActions.get(`${action.type}:${action.pluginId}:${action.id}`);
return registered ? [registered] : [];
}),
activeThemeId: this.activeThemeId,
};
for (const listener of this.listeners) listener();
}
}
@@ -0,0 +1,21 @@
import { describe, expect, test } from "bun:test";
import { parseMarketplaceRegistry } from "@edgeever/plugin-api";
import { sha256Hex } from "./github-plugin-distribution.ts";
describe("bundled plugin marketplace", () => {
test("keeps verified checksums aligned with bundled extension files", async () => {
const registry = parseMarketplaceRegistry(await Bun.file(new URL("../../../public/extensions/registry.json", import.meta.url)).json());
for (const entry of registry.entries) {
expect(entry.distribution.type).toBe("manifest");
if (entry.distribution.type !== "manifest") continue;
const relativeManifestPath = entry.distribution.manifestUrl.replace(/^\/extensions\//, "");
const manifestFileUrl = new URL(`../../../public/extensions/${relativeManifestPath}`, import.meta.url);
const manifestFile = Bun.file(manifestFileUrl);
expect(await sha256Hex(await manifestFile.text())).toBe(entry.verification.checksums?.manifestJson);
if (entry.verification.checksums?.mainJs) {
const mainFile = Bun.file(new URL("./main.js", manifestFileUrl));
expect(await sha256Hex(await mainFile.text())).toBe(entry.verification.checksums.mainJs);
}
}
});
});
@@ -0,0 +1,13 @@
import { parseMarketplaceRegistry, type MarketplaceRegistry } from "@edgeever/plugin-api";
export const DEFAULT_PLUGIN_REGISTRY_URL = "/extensions/registry.json";
export const loadPluginMarketplace = async (
registryUrl = DEFAULT_PLUGIN_REGISTRY_URL,
request: typeof fetch = window.fetch.bind(window)
): Promise<MarketplaceRegistry> => {
const url = new URL(registryUrl, window.location.href);
const response = await request(url.href, { cache: "no-store", credentials: "omit" });
if (!response.ok) throw new Error(`Plugin marketplace request failed with HTTP ${response.status}.`);
return parseMarketplaceRegistry(await response.json());
};
@@ -0,0 +1,75 @@
const DATABASE_NAME = "edgeever-plugin-packages-v1";
const DATABASE_VERSION = 1;
const PACKAGE_STORE = "packages";
export interface CachedPluginPackage {
pluginId: string;
version: string;
mainJs: string;
stylesCss: string | null;
checksums: {
mainJs: string;
manifestJson: string;
stylesCss?: string;
};
cachedAt: string;
}
export interface PluginPackageStorage {
get(pluginId: string, version: string): Promise<CachedPluginPackage | null>;
put(value: CachedPluginPackage): Promise<void>;
remove(pluginId: string): Promise<void>;
}
const requestResult = <T>(request: IDBRequest<T>) => new Promise<T>((resolve, reject) => {
request.addEventListener("success", () => resolve(request.result), { once: true });
request.addEventListener("error", () => reject(request.error ?? new Error("IndexedDB request failed.")), { once: true });
});
const transactionDone = (transaction: IDBTransaction) => new Promise<void>((resolve, reject) => {
transaction.addEventListener("complete", () => resolve(), { once: true });
transaction.addEventListener("abort", () => reject(transaction.error ?? new Error("IndexedDB transaction was aborted.")), { once: true });
transaction.addEventListener("error", () => reject(transaction.error ?? new Error("IndexedDB transaction failed.")), { once: true });
});
const openDatabase = () => new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION);
request.addEventListener("upgradeneeded", () => {
if (!request.result.objectStoreNames.contains(PACKAGE_STORE)) {
request.result.createObjectStore(PACKAGE_STORE, { keyPath: "pluginId" });
}
});
request.addEventListener("success", () => resolve(request.result), { once: true });
request.addEventListener("error", () => reject(request.error ?? new Error("Plugin package storage is unavailable.")), { once: true });
});
export class WebPluginPackageStore implements PluginPackageStorage {
private databasePromise: Promise<IDBDatabase> | null = null;
private database() {
this.databasePromise ??= openDatabase();
return this.databasePromise;
}
async get(pluginId: string, version: string) {
const database = await this.database();
const transaction = database.transaction(PACKAGE_STORE, "readonly");
const value = await requestResult(transaction.objectStore(PACKAGE_STORE).get(pluginId)) as CachedPluginPackage | undefined;
await transactionDone(transaction);
return value?.version === version ? value : null;
}
async put(value: CachedPluginPackage) {
const database = await this.database();
const transaction = database.transaction(PACKAGE_STORE, "readwrite");
transaction.objectStore(PACKAGE_STORE).put(value);
await transactionDone(transaction);
}
async remove(pluginId: string) {
const database = await this.database();
const transaction = database.transaction(PACKAGE_STORE, "readwrite");
transaction.objectStore(PACKAGE_STORE).delete(pluginId);
await transactionDone(transaction);
}
}
@@ -0,0 +1,21 @@
import { describe, expect, test } from "bun:test";
import { decryptPluginSecret, encryptPluginSecret } from "./plugin-secret-store.ts";
describe("plugin secret encryption", () => {
test("round-trips a secret with an authenticated AES-GCM payload", async () => {
const key = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]);
const encrypted = await encryptPluginSecret(key, "edgeever-secret");
expect(new TextDecoder().decode(encrypted.ciphertext)).not.toContain("edgeever-secret");
await expect(decryptPluginSecret(key, encrypted)).resolves.toBe("edgeever-secret");
});
test("rejects a modified ciphertext", async () => {
const key = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]);
const encrypted = await encryptPluginSecret(key, "edgeever-secret");
const modified = encrypted.ciphertext.slice(0);
new Uint8Array(modified)[0] ^= 1;
await expect(decryptPluginSecret(key, { ...encrypted, ciphertext: modified })).rejects.toThrow();
});
});
@@ -0,0 +1,106 @@
const DATABASE_NAME = "edgeever-plugin-secrets-v1";
const DATABASE_VERSION = 1;
const KEY_STORE = "keys";
const SECRET_STORE = "secrets";
const MASTER_KEY_ID = "device-master-key";
type EncryptedSecret = {
id: string;
iv: Uint8Array<ArrayBuffer>;
ciphertext: ArrayBuffer;
};
export const encryptPluginSecret = async (key: CryptoKey, value: string) => {
const iv = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, new TextEncoder().encode(value));
return { iv, ciphertext };
};
export const decryptPluginSecret = async (key: CryptoKey, encrypted: Pick<EncryptedSecret, "iv" | "ciphertext">) => {
const plaintext = await crypto.subtle.decrypt({ name: "AES-GCM", iv: encrypted.iv }, key, encrypted.ciphertext);
return new TextDecoder().decode(plaintext);
};
const requestResult = <T>(request: IDBRequest<T>) => new Promise<T>((resolve, reject) => {
request.addEventListener("success", () => resolve(request.result), { once: true });
request.addEventListener("error", () => reject(request.error ?? new Error("IndexedDB request failed.")), { once: true });
});
const transactionDone = (transaction: IDBTransaction) => new Promise<void>((resolve, reject) => {
transaction.addEventListener("complete", () => resolve(), { once: true });
transaction.addEventListener("abort", () => reject(transaction.error ?? new Error("IndexedDB transaction was aborted.")), { once: true });
transaction.addEventListener("error", () => reject(transaction.error ?? new Error("IndexedDB transaction failed.")), { once: true });
});
const openDatabase = () => new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION);
request.addEventListener("upgradeneeded", () => {
if (!request.result.objectStoreNames.contains(KEY_STORE)) request.result.createObjectStore(KEY_STORE);
if (!request.result.objectStoreNames.contains(SECRET_STORE)) request.result.createObjectStore(SECRET_STORE, { keyPath: "id" });
});
request.addEventListener("success", () => resolve(request.result), { once: true });
request.addEventListener("error", () => reject(request.error ?? new Error("Secret storage is unavailable.")), { once: true });
});
export interface PluginSecretStorage {
get(pluginId: string, key: string): Promise<string | null>;
set(pluginId: string, key: string, value: string): Promise<void>;
remove(pluginId: string, key: string): Promise<void>;
}
export class WebPluginSecretStore implements PluginSecretStorage {
private databasePromise: Promise<IDBDatabase> | null = null;
private masterKeyPromise: Promise<CryptoKey> | null = null;
private database() {
this.databasePromise ??= openDatabase();
return this.databasePromise;
}
private masterKey() {
this.masterKeyPromise ??= this.loadOrCreateMasterKey();
return this.masterKeyPromise;
}
private async loadOrCreateMasterKey() {
const database = await this.database();
const readTransaction = database.transaction(KEY_STORE, "readonly");
const existing = await requestResult(readTransaction.objectStore(KEY_STORE).get(MASTER_KEY_ID)) as CryptoKey | undefined;
await transactionDone(readTransaction);
if (existing) return existing;
const generated = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]);
const writeTransaction = database.transaction(KEY_STORE, "readwrite");
writeTransaction.objectStore(KEY_STORE).put(generated, MASTER_KEY_ID);
await transactionDone(writeTransaction);
return generated;
}
private recordId(pluginId: string, key: string) {
return `${pluginId}:${key}`;
}
async get(pluginId: string, key: string) {
const database = await this.database();
const transaction = database.transaction(SECRET_STORE, "readonly");
const record = await requestResult(transaction.objectStore(SECRET_STORE).get(this.recordId(pluginId, key))) as EncryptedSecret | undefined;
await transactionDone(transaction);
if (!record) return null;
return decryptPluginSecret(await this.masterKey(), record);
}
async set(pluginId: string, key: string, value: string) {
const { iv, ciphertext } = await encryptPluginSecret(await this.masterKey(), value);
const database = await this.database();
const transaction = database.transaction(SECRET_STORE, "readwrite");
transaction.objectStore(SECRET_STORE).put({ id: this.recordId(pluginId, key), iv, ciphertext } satisfies EncryptedSecret);
await transactionDone(transaction);
}
async remove(pluginId: string, key: string) {
const database = await this.database();
const transaction = database.transaction(SECRET_STORE, "readwrite");
transaction.objectStore(SECRET_STORE).delete(this.recordId(pluginId, key));
await transactionDone(transaction);
}
}
+66
View File
@@ -251,6 +251,72 @@ body {
overscroll-behavior-y: auto;
}
/* Installed theme packages only target this documented token layer. Keeping
* the selectors here lets themes survive component refactors without gaining
* arbitrary DOM or JavaScript access. */
html[data-edgeever-extension-theme] {
--radius: var(--edgeever-theme-radius, 0.5rem);
font-family: var(--edgeever-theme-font-body, inherit);
font-size: var(--edgeever-theme-font-size, 100%);
}
html[data-edgeever-extension-theme] body {
background: var(--edgeever-theme-background, #f8fafc);
color: var(--edgeever-theme-text, #0f172a);
line-height: var(--edgeever-theme-line-height, normal);
}
html[data-edgeever-extension-theme] .bg-white,
html[data-edgeever-extension-theme] .bg-slate-50,
html[data-edgeever-extension-theme] .bg-slate-50\/50 {
background-color: var(--edgeever-theme-surface, #fff);
}
html[data-edgeever-extension-theme] .bg-slate-100,
html[data-edgeever-extension-theme] .bg-slate-100\/80,
html[data-edgeever-extension-theme] .bg-slate-200\/50 {
background-color: var(--edgeever-theme-surface-muted, #f1f5f9);
}
html[data-edgeever-extension-theme] .text-slate-700,
html[data-edgeever-extension-theme] .text-slate-800,
html[data-edgeever-extension-theme] .text-slate-900,
html[data-edgeever-extension-theme] .text-slate-950 {
color: var(--edgeever-theme-text, #0f172a);
}
html[data-edgeever-extension-theme] .text-slate-400,
html[data-edgeever-extension-theme] .text-slate-500,
html[data-edgeever-extension-theme] .text-slate-600 {
color: var(--edgeever-theme-text-muted, #64748b);
}
html[data-edgeever-extension-theme] .border-slate-100,
html[data-edgeever-extension-theme] .border-slate-200,
html[data-edgeever-extension-theme] .border-slate-300,
html[data-edgeever-extension-theme] .divide-slate-100 > :not([hidden]) ~ :not([hidden]),
html[data-edgeever-extension-theme] .divide-slate-200 > :not([hidden]) ~ :not([hidden]) {
border-color: var(--edgeever-theme-border, #e2e8f0);
}
html[data-edgeever-extension-theme] .bg-emerald-600,
html[data-edgeever-extension-theme] .bg-emerald-700 {
background-color: var(--edgeever-theme-accent, #16a06e);
color: var(--edgeever-theme-accent-foreground, #fff);
}
html[data-edgeever-extension-theme] .text-emerald-600,
html[data-edgeever-extension-theme] .text-emerald-700,
html[data-edgeever-extension-theme] .text-emerald-800 {
color: var(--edgeever-theme-accent, #16a06e);
}
html[data-edgeever-extension-theme] code,
html[data-edgeever-extension-theme] pre,
html[data-edgeever-extension-theme] kbd {
font-family: var(--edgeever-theme-font-mono, ui-monospace, monospace);
}
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 180ms;
+8 -1
View File
@@ -9,6 +9,7 @@
"@ai-sdk/google": "^4.0.39",
"@ai-sdk/openai-compatible": "^3.0.27",
"@aws-sdk/client-s3": "^3.1095.0",
"@edgeever/plugin-api": "workspace:*",
"@edgeever/shared": "workspace:*",
"@hono/zod-validator": "latest",
"@radix-ui/react-checkbox": "^1.3.11",
@@ -86,7 +87,7 @@
},
"apps/desktop": {
"name": "@edgeever/desktop",
"version": "1.23.1",
"version": "1.26.0",
"dependencies": {
"electron-updater": "^6.6.2",
},
@@ -215,6 +216,10 @@
"@edgeever/shared": "workspace:*",
},
},
"packages/plugin-api": {
"name": "@edgeever/plugin-api",
"version": "0.1.0",
},
"packages/shared": {
"name": "@edgeever/shared",
"version": "0.1.3",
@@ -580,6 +585,8 @@
"@edgeever/mobile": ["@edgeever/mobile@workspace:apps/mobile"],
"@edgeever/plugin-api": ["@edgeever/plugin-api@workspace:packages/plugin-api"],
"@edgeever/shared": ["@edgeever/shared@workspace:packages/shared"],
"@edgeever/site": ["@edgeever/site@workspace:apps/site"],
+269
View File
@@ -0,0 +1,269 @@
# EdgeEver Plugin Development (P0 Preview)
EdgeEver's P0 extension API supports trusted client plugins and no-code theme packages. Users can install extensions from the verified marketplace, a public GitHub repository, or a manifest URL. Extensions are installed per device and run only while EdgeEver is open. Scheduled/background jobs, webhooks, custom editor blocks, and a hard JavaScript sandbox are not part of this preview.
## Security model
Theme packages contain only a validated manifest and documented design tokens. They do not execute JavaScript.
Client plugins use an Obsidian-style trusted-code model. Their declared permissions gate calls made through the EdgeEver plugin context, but the plugin module itself runs in the client JavaScript environment. Users must install plugins only from developers they trust.
Plugins never receive EdgeEver's repository, IndexedDB database, Cloudflare bindings, or internal React state through the public API.
## Plugin manifest
```json
{
"type": "plugin",
"id": "com.example.recent-notes",
"name": "Recent Notes",
"version": "1.0.0",
"apiVersion": "1",
"description": "Adds a command for recent notes.",
"entry": "./main.js",
"platforms": ["web", "desktop"],
"permissions": ["notes:read", "editor:read", "ui:commands", "ui:notices", "ui:panels"]
}
```
The manifest and JavaScript module must be served with CORS headers that permit the EdgeEver origin. Relative `entry` paths resolve against the manifest URL.
## GitHub distribution
Developers can share a public GitHub repository URL directly with users. The default branch must contain the latest `manifest.json` at its root, and every version is published as a GitHub Release. The Release tag may be the manifest version or its `v`-prefixed form, such as `1.2.0` or `v1.2.0`.
Attach these files to the Release:
```text
manifest.json
main.js
styles.css (optional)
```
GitHub plugins must use `./main.js` as `entry`, and `main.js` must be a single-file bundle without relative module imports. EdgeEver reads the default-branch manifest, locates the matching Release, downloads assets in parallel, verifies GitHub's SHA-256 digest when present, and caches the verified package in device-local IndexedDB. `main.js` is limited to 5 MB and `styles.css` to 1 MB.
Users can freely install without marketplace admission by pasting this into the standalone Plugin Marketplace page:
```text
https://github.com/owner/edgeever-plugin
```
Only public GitHub repositories are supported for now; private-repository tokens are not exposed.
## Verified plugin marketplace
The marketplace is a verified Registry and does not take ownership of plugin files. For each version, the Registry pins the plugin ID, GitHub repository, version, and SHA-256 hashes for `manifest.json`, `main.js`, and optional `styles.css`. Installation still downloads from the developer's GitHub Release or registered public URL and verifies those hashes again.
Registry format:
```json
{
"registryVersion": "1",
"updatedAt": "2026-08-16T00:00:00.000Z",
"entries": [{
"id": "com.example.recent-notes",
"name": "Recent Notes",
"description": "Shows recently updated notes.",
"author": "Example",
"category": "Productivity",
"repositoryUrl": "https://github.com/example/edgeever-recent-notes",
"distribution": {
"type": "github",
"repositoryUrl": "https://github.com/example/edgeever-recent-notes"
},
"verification": {
"version": "1.0.0",
"checksums": {
"manifestJson": "<64-character SHA-256>",
"mainJs": "<64-character SHA-256>"
}
}
}]
}
```
Marketplace installs display a Verified badge. GitHub and manifest sideloads clearly display their unverified source, but EdgeEver does not block them. Uninstalling also deletes the device-local cached package.
Supported permissions:
- `notes:read`
- `notes:write`
- `notes:delete`
- `metadata:read`
- `metadata:write`
- `network`
- `storage`
- `secrets`
- `editor:read`
- `editor:write`
- `ui:commands`
- `ui:notices`
- `ui:panels`
Network access through `context.network.fetch()` also requires a `networkHosts` allowlist in the manifest.
## Plugin entry
```js
export default {
activate(context) {
return context.commands.register({
id: "count-recent-notes",
title: "Count recent notes",
async run() {
const result = await context.notes.query({
sort: "updated-desc",
limit: 10
});
context.ui.showNotice(`${result.notes.length} recent notes`);
}
});
}
};
```
For TypeScript projects, import contracts and helpers from `@edgeever/plugin-api`:
```ts
import { definePlugin } from "@edgeever/plugin-api";
export default definePlugin({
activate(context) {
// Register commands and event listeners here.
}
});
```
Every registration returns a disposer. The host also disposes registered commands and events automatically when a plugin is disabled.
## Notes API
```ts
context.notes.query({ text, notebookId, tags, sort, limit, offset });
context.notes.get(noteId);
context.notes.create({ notebookId, title, contentMarkdown, tags });
context.notes.update(noteId, { title, contentMarkdown, tags });
context.notes.delete(noteId, { permanent: false });
context.notebooks.list();
context.tags.list();
context.tags.rename("old", "new");
context.tags.delete("unused");
```
All writes go through EdgeEver's shared repository/business layer, including offline queueing and desktop adapters. Plugins do not access a storage implementation directly.
Notebook and tag reads require `metadata:read`; tag changes require `metadata:write`.
## Storage and network
Plugin storage is namespaced by EdgeEver workspace and plugin ID:
```ts
await context.storage.set("cursor", "next-page");
const cursor = await context.storage.get<string>("cursor");
```
Requests are limited to HTTPS, except localhost development, and to declared hosts:
```json
{
"permissions": ["network"],
"networkHosts": ["api.example.com", "*.trusted.example.com"]
}
```
```ts
await context.network.fetch("https://api.example.com/items");
```
Use regular `storage` for cursors and preferences. Sensitive strings such as API keys belong in `secrets`:
```ts
await context.secrets.set("api-token", token);
const token = await context.secrets.get("api-token");
await context.secrets.remove("api-token");
```
The web host namespaces secrets by workspace and plugin ID, encrypts them with AES-GCM using a device-local, non-exportable WebCrypto key, and stores ciphertext in IndexedDB. This prevents plaintext storage, but P0 plugins are trusted same-page code and the mechanism cannot defend against a malicious plugin reading live data.
## Editor selection API
`editor:read` reads the active editor selection. `editor:write` replaces it or inserts Markdown at the cursor:
```ts
const selection = await context.editor.getSelection();
if (selection && !selection.empty) {
await context.editor.replaceSelection(selection.text.toUpperCase());
}
await context.editor.insertAtCursor("**Inserted by plugin**");
```
Reading returns `null` when no editable note is open; writes throw an error. Plugin edits use normal editor transactions and the autosave flow.
## Custom panels
Plugins can register framework-independent DOM panels. Users open them from the Plugin Marketplace's installed-plugin section; the host runs the disposer when the panel closes or the plugin is disabled or uninstalled:
```ts
context.ui.panels.register({
id: "dashboard",
title: "Dashboard",
mount(container) {
const heading = document.createElement("h2");
heading.textContent = "Plugin dashboard";
container.append(heading);
return () => heading.remove();
}
});
```
## Desktop plugin entry
After a plugin is enabled, a unified puzzle button appears in the top-right of the desktop editor and settings page. Its menu groups commands and panels by plugin, keeps recently used actions at the top, and links directly to extension management. Individual plugins do not each consume a toolbar icon.
## Theme manifest
Themes are code-free extension packages:
```json
{
"type": "theme",
"id": "com.example.theme",
"name": "Example Theme",
"version": "1.0.0",
"themeApiVersion": "1",
"modes": ["light", "dark"],
"light": {
"color.background": "#f8fafc",
"color.surface": "#ffffff",
"color.text": "#0f172a",
"color.accent": "#16a06e"
},
"dark": {
"color.background": "#0f172a",
"color.surface": "#1e293b",
"color.text": "#f8fafc",
"color.accent": "#4ade80"
}
}
```
Supported token names are exported as `THEME_TOKEN_NAMES` by `@edgeever/plugin-api`. Unknown tokens are rejected so themes do not depend on private DOM selectors.
Color tokens accept only `#RRGGBB` or `#RRGGBBAA`; font and size tokens also use restricted formats. Theme values cannot contain selectors, remote assets, or CSS functions.
## Bundled examples
When developing EdgeEver locally, install these URLs from the standalone **Plugin Marketplace** page:
- `/extensions/recent-notes/manifest.json`
- `/extensions/nord-emerald/manifest.json`
The first demonstrates note queries, selection replacement, commands, and a custom panel. The second demonstrates the code-free theme token API.
## Current limits
- Plugins are installed on one device and are not synchronized.
- Plugins run only while the app is open.
- There is no Cron, webhook receiver, background runtime, marketplace submission backend, or automated review pipeline.
- Declared permissions are API capability checks, not a hard sandbox for trusted JavaScript.
- Custom panels open from the unified desktop plugin menu or extension settings and cannot yet be pinned to the main navigation or editor sidebar.
- Secret storage is device-local and does not sync to other devices.
+269
View File
@@ -0,0 +1,269 @@
# EdgeEver 插件开发(P0 预览版)
EdgeEver P0 扩展 API 支持受信任的客户端插件和无代码主题包。用户可以从已验证插件市场、公开 GitHub 仓库或 Manifest 地址安装扩展;扩展安装在当前设备,并且只在 EdgeEver 打开期间运行。当前预览版不包含定时或后台任务、Webhook、自定义编辑器 Block 和严格的 JavaScript 沙箱。
## 安全模型
主题包只包含经过校验的 Manifest 和公开 Design Tokens,不执行 JavaScript。
客户端插件采用类似 Obsidian 的受信任代码模型。插件声明的权限会限制它通过 EdgeEver 插件上下文调用的 API,但插件模块本身仍运行在客户端 JavaScript 环境中。用户只能安装来自可信开发者的插件。
公开 API 不会向插件暴露 EdgeEver Repository、IndexedDB 数据库、Cloudflare Binding 或 React 内部状态。
## 插件 Manifest
```json
{
"type": "plugin",
"id": "com.example.recent-notes",
"name": "Recent Notes",
"version": "1.0.0",
"apiVersion": "1",
"description": "Adds a command for recent notes.",
"entry": "./main.js",
"platforms": ["web", "desktop"],
"permissions": ["notes:read", "editor:read", "ui:commands", "ui:notices", "ui:panels"]
}
```
Manifest 和 JavaScript 模块必须返回允许 EdgeEver 来源访问的 CORS 响应头。相对 `entry` 地址基于 Manifest 地址解析。
## 通过 GitHub 分发
开发者可以把公开 GitHub 仓库地址直接分享给用户。仓库默认分支根目录必须包含最新的 `manifest.json`,每个版本通过 GitHub Release 发布。Release Tag 使用 Manifest 中的版本号或带 `v` 前缀的版本号,例如 `1.2.0` 或 `v1.2.0`。
Release 必须上传以下资产:
```text
manifest.json
main.js
styles.css(可选)
```
GitHub 插件的 `entry` 固定为 `./main.js`,`main.js` 必须是无需相对模块导入的单文件 Bundle。EdgeEver 会读取默认分支 Manifest、查找相同版本的 Release、并行下载资产、验证 GitHub 提供的 SHA-256 Digest(如果存在),然后把验证后的包缓存到当前设备的 IndexedDB。`main.js` 上限为 5 MB,`styles.css` 上限为 1 MB。
用户在独立的「插件市场」页面中粘贴以下地址即可自由安装,无需经过官方市场收录:
```text
https://github.com/owner/edgeever-plugin
```
目前仅支持公开 GitHub 仓库;私有仓库 Token 尚未开放。
## 已验证插件市场
插件市场是一个经过校验的 Registry,不接管插件所有权。Registry 为每个版本固定插件 ID、GitHub 仓库、版本号及 `manifest.json`/`main.js`/`styles.css` 的 SHA-256;安装时仍从开发者的 GitHub Release 或登记的公开地址下载,并再次核对校验和。
Registry 格式:
```json
{
"registryVersion": "1",
"updatedAt": "2026-08-16T00:00:00.000Z",
"entries": [{
"id": "com.example.recent-notes",
"name": "Recent Notes",
"description": "Shows recently updated notes.",
"author": "Example",
"category": "Productivity",
"repositoryUrl": "https://github.com/example/edgeever-recent-notes",
"distribution": {
"type": "github",
"repositoryUrl": "https://github.com/example/edgeever-recent-notes"
},
"verification": {
"version": "1.0.0",
"checksums": {
"manifestJson": "<64 位 SHA-256>",
"mainJs": "<64 位 SHA-256>"
}
}
}]
}
```
市场安装显示“已验证”,GitHub 或 Manifest 自由安装会明确显示未经验证的来源,但 EdgeEver 不阻止用户安装。卸载插件时会同时删除本机缓存的插件包。
当前支持以下权限:
- `notes:read`
- `notes:write`
- `notes:delete`
- `metadata:read`
- `metadata:write`
- `network`
- `storage`
- `secrets`
- `editor:read`
- `editor:write`
- `ui:commands`
- `ui:notices`
- `ui:panels`
通过 `context.network.fetch()` 访问网络时,还必须在 Manifest 的 `networkHosts` 中声明目标域名。
## 插件入口
```js
export default {
activate(context) {
return context.commands.register({
id: "count-recent-notes",
title: "Count recent notes",
async run() {
const result = await context.notes.query({
sort: "updated-desc",
limit: 10
});
context.ui.showNotice(`${result.notes.length} recent notes`);
}
});
}
};
```
TypeScript 项目可以从 `@edgeever/plugin-api` 导入类型与辅助函数:
```ts
import { definePlugin } from "@edgeever/plugin-api";
export default definePlugin({
activate(context) {
// 在这里注册命令和事件监听器。
}
});
```
每次注册都会返回清理函数。插件停用时,宿主也会自动清理已注册的命令和事件。
## 笔记 API
```ts
context.notes.query({ text, notebookId, tags, sort, limit, offset });
context.notes.get(noteId);
context.notes.create({ notebookId, title, contentMarkdown, tags });
context.notes.update(noteId, { title, contentMarkdown, tags });
context.notes.delete(noteId, { permanent: false });
context.notebooks.list();
context.tags.list();
context.tags.rename("old", "new");
context.tags.delete("unused");
```
所有写入都经过 EdgeEver 的共享 Repository 和业务层,包括离线队列与桌面端适配器。插件不能直接访问具体存储实现。
读取笔记本和标签需要 `metadata:read`,修改标签需要 `metadata:write`。
## 插件存储与网络
插件存储按照 EdgeEver 工作区和插件 ID 隔离:
```ts
await context.storage.set("cursor", "next-page");
const cursor = await context.storage.get<string>("cursor");
```
网络请求只能使用 HTTPS;本地开发允许 localhost HTTP,并且目标域名必须提前声明:
```json
{
"permissions": ["network"],
"networkHosts": ["api.example.com", "*.trusted.example.com"]
}
```
```ts
await context.network.fetch("https://api.example.com/items");
```
普通 `storage` 适合游标和偏好设置。API Key 等敏感字符串应使用 `secrets`:
```ts
await context.secrets.set("api-token", token);
const token = await context.secrets.get("api-token");
await context.secrets.remove("api-token");
```
Web 端按照工作区和插件 ID 隔离 Secret,并使用设备本地、不可导出的 WebCrypto 密钥进行 AES-GCM 加密,密文保存在 IndexedDB。它可以避免密钥以明文形式落盘,但由于 P0 插件是同页面受信任代码,不能防御恶意插件读取运行中的数据。
## 编辑器选区 API
`editor:read` 可以读取当前编辑器选区,`editor:write` 可以替换选区或在光标处插入 Markdown:
```ts
const selection = await context.editor.getSelection();
if (selection && !selection.empty) {
await context.editor.replaceSelection(selection.text.toUpperCase());
}
await context.editor.insertAtCursor("**Inserted by plugin**");
```
没有打开可编辑笔记时,读取返回 `null`,写入会抛出错误。插件修改会进入正常的编辑器事务和自动保存流程。
## 自定义面板
插件可以注册框架无关的 DOM 面板。用户从「插件市场」的已安装插件区域打开面板,关闭、停用或卸载插件时宿主会执行清理函数:
```ts
context.ui.panels.register({
id: "dashboard",
title: "Dashboard",
mount(container) {
const heading = document.createElement("h2");
heading.textContent = "Plugin dashboard";
container.append(heading);
return () => heading.remove();
}
});
```
## 桌面端插件入口
启用插件后,桌面端编辑器和个人中心右上角会显示统一的拼图入口。菜单按插件分组展示命令和面板,并在顶部保留最近使用的操作;“管理插件与主题”会直接打开独立插件市场页面。插件不会各自在工具栏占用一个图标。
## 主题 Manifest
主题是一种不包含代码的扩展包:
```json
{
"type": "theme",
"id": "com.example.theme",
"name": "Example Theme",
"version": "1.0.0",
"themeApiVersion": "1",
"modes": ["light", "dark"],
"light": {
"color.background": "#f8fafc",
"color.surface": "#ffffff",
"color.text": "#0f172a",
"color.accent": "#16a06e"
},
"dark": {
"color.background": "#0f172a",
"color.surface": "#1e293b",
"color.text": "#f8fafc",
"color.accent": "#4ade80"
}
}
```
`@edgeever/plugin-api` 会通过 `THEME_TOKEN_NAMES` 导出所有支持的 Token。未知 Token 会被拒绝,避免主题依赖私有 DOM 选择器。
颜色 Token 只接受 `#RRGGBB` 或 `#RRGGBBAA`,字体与尺寸 Token 同样使用受限格式。主题值不能包含选择器、远程资源或 CSS 函数。
## 仓库内示例
本地开发 EdgeEver 时,可以在独立的「插件市场」页面中安装:
- `/extensions/recent-notes/manifest.json`
- `/extensions/nord-emerald/manifest.json`
第一个示例演示笔记查询、选区替换、命令和自定义面板,第二个示例演示无代码主题 Token API。
## 当前限制
- 插件只安装在当前设备,不参与同步。
- 插件只在应用打开期间运行。
- 暂无 Cron、Webhook 接收端、后台运行环境、市场投稿后台和自动审核流水线。
- 权限声明属于 API 能力检查,不是针对受信任 JavaScript 的严格沙箱。
- 自定义面板可以从桌面端统一插件菜单或插件管理页打开,尚未支持固定到主导航或编辑器侧栏。
- Secret Storage 仅保存在当前设备,不会同步到其他设备。
+1
View File
@@ -116,6 +116,7 @@
"@ai-sdk/google": "^4.0.39",
"@ai-sdk/openai-compatible": "^3.0.27",
"@aws-sdk/client-s3": "^3.1095.0",
"@edgeever/plugin-api": "workspace:*",
"@edgeever/shared": "workspace:*",
"@hono/zod-validator": "latest",
"@radix-ui/react-checkbox": "^1.3.11",
+10
View File
@@ -0,0 +1,10 @@
{
"name": "@edgeever/plugin-api",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
},
"types": "./src/index.ts"
}
+97
View File
@@ -0,0 +1,97 @@
import { describe, expect, test } from "bun:test";
import { parseExtensionManifest, parseMarketplaceRegistry } from "./index.ts";
describe("extension manifests", () => {
test("normalizes a plugin manifest", () => {
expect(parseExtensionManifest({
type: "plugin",
id: "org.edgeever.example",
name: "Example",
version: "1.0.0",
apiVersion: "1",
entry: "./main.js",
permissions: ["notes:read", "notes:read", "ui:commands"],
})).toMatchObject({ permissions: ["notes:read", "ui:commands"] });
});
test("rejects undeclared permissions", () => {
expect(() => parseExtensionManifest({
type: "plugin",
id: "org.edgeever.bad",
name: "Bad",
version: "1.0.0",
apiVersion: "1",
entry: "./main.js",
permissions: ["database:raw"],
})).toThrow("Unsupported plugin permission");
});
test("rejects unknown theme tokens", () => {
expect(() => parseExtensionManifest({
type: "theme",
id: "org.edgeever.theme",
name: "Theme",
version: "1.0.0",
themeApiVersion: "1",
modes: ["light"],
light: { "unsafe.selector": "body" },
})).toThrow("Unsupported theme token");
});
test("rejects CSS injection through theme values", () => {
expect(() => parseExtensionManifest({
type: "theme",
id: "org.edgeever.remote-theme",
name: "Remote Theme",
version: "1.0.0",
themeApiVersion: "1",
modes: ["light"],
light: { "color.background": "url(https://example.com/track)" },
})).toThrow("must use #RRGGBB");
});
test("requires an allowlist for network plugins", () => {
expect(() => parseExtensionManifest({
type: "plugin",
id: "org.edgeever.network",
name: "Network",
version: "1.0.0",
apiVersion: "1",
entry: "./main.js",
permissions: ["network"],
})).toThrow("must declare networkHosts");
});
});
describe("marketplace registry", () => {
test("accepts a verified GitHub entry", () => {
expect(parseMarketplaceRegistry({
registryVersion: "1",
updatedAt: "2026-08-16T00:00:00.000Z",
entries: [{
id: "org.edgeever.example",
name: "Example",
description: "Example plugin",
author: "EdgeEver",
category: "Productivity",
repositoryUrl: "https://github.com/edgeever/example",
distribution: { type: "github", repositoryUrl: "https://github.com/edgeever/example" },
verification: { version: "1.0.0", checksums: { manifestJson: "a".repeat(64), mainJs: "b".repeat(64) } },
}],
}).entries[0]).toMatchObject({ id: "org.edgeever.example", verification: { version: "1.0.0" } });
});
test("rejects duplicate plugin ids", () => {
const entry = {
id: "org.edgeever.example",
name: "Example",
description: "Example plugin",
author: "EdgeEver",
category: "Productivity",
repositoryUrl: "https://github.com/edgeever/example",
distribution: { type: "github", repositoryUrl: "https://github.com/edgeever/example" },
verification: { version: "1.0.0", checksums: { manifestJson: "a".repeat(64) } },
};
expect(() => parseMarketplaceRegistry({ registryVersion: "1", updatedAt: "2026-08-16T00:00:00Z", entries: [entry, entry] })).toThrow("Duplicate");
});
});
+412
View File
@@ -0,0 +1,412 @@
export const PLUGIN_API_VERSION = "1" as const;
export const THEME_API_VERSION = "1" as const;
export const PLUGIN_PERMISSIONS = [
"notes:read",
"notes:write",
"notes:delete",
"metadata:read",
"metadata:write",
"network",
"storage",
"secrets",
"editor:read",
"editor:write",
"ui:commands",
"ui:notices",
"ui:panels",
] as const;
export type PluginPermission = (typeof PLUGIN_PERMISSIONS)[number];
export type ExtensionPlatform = "web" | "desktop" | "android" | "ios";
export interface PluginManifest {
type: "plugin";
id: string;
name: string;
version: string;
apiVersion: typeof PLUGIN_API_VERSION;
description?: string;
author?: string;
entry: string;
platforms?: ExtensionPlatform[];
permissions: PluginPermission[];
networkHosts?: string[];
}
export const THEME_TOKEN_NAMES = [
"color.background",
"color.surface",
"color.surfaceMuted",
"color.text",
"color.textMuted",
"color.border",
"color.accent",
"color.accentForeground",
"color.success",
"color.warning",
"color.danger",
"font.body",
"font.mono",
"font.size",
"lineHeight.body",
"radius.medium",
"density.scale",
"editor.contentWidth",
] as const;
export type ThemeTokenName = (typeof THEME_TOKEN_NAMES)[number];
export type ThemeTokens = Partial<Record<ThemeTokenName, string>>;
export interface ThemeManifest {
type: "theme";
id: string;
name: string;
version: string;
themeApiVersion: typeof THEME_API_VERSION;
description?: string;
author?: string;
modes: Array<"light" | "dark">;
light: ThemeTokens;
dark?: ThemeTokens;
}
export type ExtensionManifest = PluginManifest | ThemeManifest;
export const MARKETPLACE_REGISTRY_VERSION = "1" as const;
export interface MarketplaceEntry {
id: string;
name: string;
description: string;
author: string;
category: string;
repositoryUrl: string;
distribution:
| { type: "github"; repositoryUrl: string }
| { type: "manifest"; manifestUrl: string };
verification: {
version: string;
checksums?: {
manifestJson?: string;
mainJs?: string;
stylesCss?: string;
};
};
}
export interface MarketplaceRegistry {
registryVersion: typeof MARKETPLACE_REGISTRY_VERSION;
updatedAt: string;
entries: MarketplaceEntry[];
}
export interface PluginNoteSummary {
id: string;
notebookId: string;
title: string | null;
excerpt: string;
tags: string[];
isPinned: boolean;
createdAt: string;
updatedAt: string;
}
export interface PluginNote extends PluginNoteSummary {
contentMarkdown: string;
contentText: string;
}
export interface PluginNoteQuery {
notebookId?: string;
text?: string;
tags?: string[];
sort?: "updated-desc" | "created-desc" | "title-asc";
limit?: number;
offset?: number;
}
export interface PluginNoteCreateInput {
notebookId: string;
title?: string;
contentMarkdown?: string;
tags?: string[];
}
export interface PluginNoteUpdateInput {
title?: string;
contentMarkdown?: string;
tags?: string[];
}
export interface PluginNoteQueryResult {
notes: PluginNoteSummary[];
totalCount: number;
nextOffset: number | null;
}
export interface PluginNotebook {
id: string;
parentId: string | null;
name: string;
memoCount: number;
}
export interface PluginTag {
name: string;
noteCount: number;
}
export type PluginEventMap = {
"note.created": { note: PluginNote };
"note.updated": { note: PluginNote };
"note.deleted": { noteId: string };
"tag.changed": { previousName?: string; name?: string; deleted?: boolean };
"workspace.sync-queue-changed": Record<string, never>;
};
export interface PluginCommand {
id: string;
title: string;
run: () => void | Promise<void>;
}
export interface PluginEditorSelection {
noteId: string;
from: number;
to: number;
empty: boolean;
text: string;
contentMarkdown: string;
}
export interface PluginPanel {
id: string;
title: string;
mount(container: HTMLElement): void | (() => void) | Promise<void | (() => void)>;
}
export interface PluginContext {
pluginId: string;
notes: {
query(input?: PluginNoteQuery): Promise<PluginNoteQueryResult>;
get(noteId: string): Promise<PluginNote>;
create(input: PluginNoteCreateInput): Promise<PluginNote>;
update(noteId: string, input: PluginNoteUpdateInput): Promise<PluginNote>;
delete(noteId: string, options?: { permanent?: boolean }): Promise<void>;
};
notebooks: {
list(): Promise<PluginNotebook[]>;
};
tags: {
list(): Promise<PluginTag[]>;
rename(name: string, nextName: string): Promise<number>;
delete(name: string): Promise<number>;
};
commands: {
register(command: PluginCommand): () => void;
};
events: {
on<K extends keyof PluginEventMap>(event: K, listener: (payload: PluginEventMap[K]) => void): () => void;
};
storage: {
get<T>(key: string): Promise<T | null>;
set<T>(key: string, value: T): Promise<void>;
remove(key: string): Promise<void>;
};
secrets: {
get(key: string): Promise<string | null>;
set(key: string, value: string): Promise<void>;
remove(key: string): Promise<void>;
};
editor: {
getSelection(): Promise<PluginEditorSelection | null>;
replaceSelection(contentMarkdown: string): Promise<void>;
insertAtCursor(contentMarkdown: string): Promise<void>;
};
network: {
fetch(input: string, init?: RequestInit): Promise<Response>;
};
ui: {
showNotice(message: string): void;
panels: {
register(panel: PluginPanel): () => void;
};
};
}
export interface EdgeEverPlugin {
activate(context: PluginContext): void | (() => void) | Promise<void | (() => void)>;
deactivate?(): void | Promise<void>;
}
export const definePlugin = <T extends EdgeEverPlugin>(plugin: T): T => plugin;
export const defineTheme = <T extends ThemeManifest>(theme: T): T => theme;
const ID_PATTERN = /^[a-z0-9]+(?:[._-][a-z0-9]+)+$/;
const VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const COLOR_THEME_TOKENS = new Set<ThemeTokenName>([
"color.background", "color.surface", "color.surfaceMuted", "color.text", "color.textMuted",
"color.border", "color.accent", "color.accentForeground", "color.success", "color.warning", "color.danger",
]);
const FONT_THEME_TOKENS = new Set<ThemeTokenName>(["font.body", "font.mono"]);
const LENGTH_THEME_TOKENS = new Set<ThemeTokenName>(["font.size", "radius.medium", "editor.contentWidth"]);
const validateThemeToken = (key: ThemeTokenName, value: string) => {
if (COLOR_THEME_TOKENS.has(key) && !/^#[0-9a-f]{6}(?:[0-9a-f]{2})?$/i.test(value)) {
throw new Error(`Theme color token ${key} must use #RRGGBB or #RRGGBBAA.`);
}
if (FONT_THEME_TOKENS.has(key) && (!/^[a-z0-9 ,_'"-]+$/i.test(value) || value.length > 200)) {
throw new Error(`Theme font token ${key} contains unsupported characters.`);
}
if (LENGTH_THEME_TOKENS.has(key) && !/^\d+(?:\.\d+)?(?:px|rem|em|%)$/.test(value)) {
throw new Error(`Theme length token ${key} must use px, rem, em, or %.`);
}
if (key === "lineHeight.body" && !/^\d+(?:\.\d+)?(?:px|rem|em|%)?$/.test(value)) {
throw new Error("Theme lineHeight.body must be a number or a supported CSS length.");
}
if (key === "density.scale" && !/^(?:0\.[5-9]\d?|1(?:\.\d{1,2})?)$/.test(value)) {
throw new Error("Theme density.scale must be between 0.5 and 1.99.");
}
};
const assertCommonManifest = (value: Record<string, unknown>) => {
if (typeof value.id !== "string" || !ID_PATTERN.test(value.id)) {
throw new Error("Extension id must be a reverse-domain style identifier using lowercase letters, numbers, dots, dashes, or underscores.");
}
if (typeof value.name !== "string" || !value.name.trim()) {
throw new Error("Extension name is required.");
}
if (typeof value.version !== "string" || !VERSION_PATTERN.test(value.version)) {
throw new Error("Extension version must use SemVer.");
}
};
const normalizeThemeTokens = (value: unknown): ThemeTokens => {
if (!isRecord(value)) throw new Error("Theme tokens must be an object.");
const allowed = new Set<string>(THEME_TOKEN_NAMES);
const tokens: ThemeTokens = {};
for (const [key, tokenValue] of Object.entries(value)) {
if (!allowed.has(key)) throw new Error(`Unsupported theme token: ${key}`);
if (typeof tokenValue !== "string" || !tokenValue.trim()) throw new Error(`Theme token ${key} must be a non-empty string.`);
const normalizedValue = tokenValue.trim();
validateThemeToken(key as ThemeTokenName, normalizedValue);
tokens[key as ThemeTokenName] = normalizedValue;
}
return tokens;
};
export const parseExtensionManifest = (value: unknown): ExtensionManifest => {
if (!isRecord(value)) throw new Error("Extension manifest must be an object.");
assertCommonManifest(value);
if (value.type === "plugin") {
if (value.apiVersion !== PLUGIN_API_VERSION) throw new Error(`Unsupported plugin API version: ${String(value.apiVersion)}`);
if (typeof value.entry !== "string" || !value.entry.trim()) throw new Error("Plugin entry is required.");
if (!Array.isArray(value.permissions)) throw new Error("Plugin permissions must be an array.");
const allowedPermissions = new Set<string>(PLUGIN_PERMISSIONS);
const permissions = [...new Set(value.permissions.map(String))];
const unsupported = permissions.find((permission) => !allowedPermissions.has(permission));
if (unsupported) throw new Error(`Unsupported plugin permission: ${unsupported}`);
const networkHosts = value.networkHosts === undefined
? undefined
: Array.isArray(value.networkHosts)
? value.networkHosts.map(String)
: (() => { throw new Error("networkHosts must be an array."); })();
if (networkHosts?.some((host) => !/^(?:\*\.)?[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/i.test(host))) {
throw new Error("networkHosts entries must be hostnames without a scheme, port, or path.");
}
if (permissions.includes("network") && !networkHosts?.length) {
throw new Error("Plugins requesting network permission must declare networkHosts.");
}
const platforms = value.platforms === undefined
? undefined
: Array.isArray(value.platforms) && value.platforms.every((platform) => ["web", "desktop", "android", "ios"].includes(String(platform)))
? [...new Set(value.platforms.map(String))] as ExtensionPlatform[]
: (() => { throw new Error("Plugin platforms contains an unsupported platform."); })();
return { ...value, type: "plugin", permissions, networkHosts, platforms } as PluginManifest;
}
if (value.type === "theme") {
if (value.themeApiVersion !== THEME_API_VERSION) throw new Error(`Unsupported theme API version: ${String(value.themeApiVersion)}`);
if (!Array.isArray(value.modes) || value.modes.length === 0 || value.modes.some((mode) => mode !== "light" && mode !== "dark")) {
throw new Error("Theme modes must contain light and/or dark.");
}
return {
...value,
type: "theme",
modes: [...new Set(value.modes)] as Array<"light" | "dark">,
light: normalizeThemeTokens(value.light),
dark: value.dark === undefined ? undefined : normalizeThemeTokens(value.dark),
} as ThemeManifest;
}
throw new Error("Extension type must be plugin or theme.");
};
const normalizeChecksum = (value: unknown, label: string) => {
if (typeof value !== "string" || !/^[0-9a-f]{64}$/i.test(value)) throw new Error(`${label} must be a SHA-256 hex digest.`);
return value.toLocaleLowerCase();
};
const GITHUB_REPOSITORY_PATTERN = /^https:\/\/github\.com\/[^/]+\/[^/]+\/?$/i;
export const parseMarketplaceRegistry = (value: unknown): MarketplaceRegistry => {
if (!isRecord(value) || value.registryVersion !== MARKETPLACE_REGISTRY_VERSION || !Array.isArray(value.entries)) {
throw new Error("Unsupported marketplace registry format.");
}
if (typeof value.updatedAt !== "string" || Number.isNaN(Date.parse(value.updatedAt))) {
throw new Error("Marketplace registry updatedAt must be an ISO date.");
}
const ids = new Set<string>();
const entries = value.entries.map((item): MarketplaceEntry => {
if (!isRecord(item) || typeof item.id !== "string" || !ID_PATTERN.test(item.id)) throw new Error("Marketplace entry id is invalid.");
if (ids.has(item.id)) throw new Error(`Duplicate marketplace entry id: ${item.id}`);
ids.add(item.id);
for (const field of ["name", "description", "author", "category", "repositoryUrl"] as const) {
if (typeof item[field] !== "string" || !item[field].trim()) throw new Error(`Marketplace entry ${item.id} is missing ${field}.`);
}
const name = item.name as string;
const description = item.description as string;
const author = item.author as string;
const category = item.category as string;
const repositoryUrl = item.repositoryUrl as string;
if (!GITHUB_REPOSITORY_PATTERN.test(repositoryUrl)) throw new Error(`Marketplace entry ${item.id} repositoryUrl must be a GitHub repository.`);
if (!isRecord(item.distribution) || (item.distribution.type !== "github" && item.distribution.type !== "manifest")) {
throw new Error(`Marketplace entry ${item.id} has an invalid distribution.`);
}
const distribution = item.distribution.type === "github"
? typeof item.distribution.repositoryUrl === "string" && GITHUB_REPOSITORY_PATTERN.test(item.distribution.repositoryUrl)
? { type: "github" as const, repositoryUrl: item.distribution.repositoryUrl }
: (() => { throw new Error(`Marketplace entry ${item.id} has an invalid GitHub repository.`); })()
: typeof item.distribution.manifestUrl === "string" && (/^https:\/\//i.test(item.distribution.manifestUrl) || item.distribution.manifestUrl.startsWith("/"))
? { type: "manifest" as const, manifestUrl: item.distribution.manifestUrl }
: (() => { throw new Error(`Marketplace entry ${item.id} has an invalid manifest URL.`); })();
if (!isRecord(item.verification) || typeof item.verification.version !== "string" || !VERSION_PATTERN.test(item.verification.version)) {
throw new Error(`Marketplace entry ${item.id} has an invalid verified version.`);
}
const checksums = item.verification.checksums === undefined
? undefined
: isRecord(item.verification.checksums)
? Object.fromEntries(Object.entries(item.verification.checksums).map(([key, checksum]) => {
if (!["manifestJson", "mainJs", "stylesCss"].includes(key)) throw new Error(`Marketplace entry ${item.id} has an unsupported checksum.`);
return [key, normalizeChecksum(checksum, `${item.id} ${key}`)];
})) as MarketplaceEntry["verification"]["checksums"]
: (() => { throw new Error(`Marketplace entry ${item.id} checksums must be an object.`); })();
if (!checksums?.manifestJson) throw new Error(`Marketplace entry ${item.id} must pin the manifest.json checksum.`);
return {
id: item.id,
name: name.trim(),
description: description.trim(),
author: author.trim(),
category: category.trim(),
repositoryUrl: repositoryUrl.trim(),
distribution,
verification: { version: item.verification.version, checksums },
};
});
return { registryVersion: MARKETPLACE_REGISTRY_VERSION, updatedAt: value.updatedAt, entries };
};
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"composite": false,
"noEmit": true
},
"include": ["src/**/*.ts"]
}
+38
View File
@@ -258,6 +258,44 @@ export const enUS = {
account: "Login Settings",
},
},
plugins: {
title: "Plugins and themes",
manifestUrl: "Extension manifest URL",
manifestPlaceholder: "https://example.com/edgeever-plugin/manifest.json",
installSource: "GitHub repository or manifest URL",
sourcePlaceholder: "https://github.com/owner/edgeever-plugin",
install: "Install",
installing: "Installing…",
empty: "No plugins or themes are installed on this device.",
toggle: "Enable or disable {{name}}",
uninstall: "Uninstall",
localOnly: "P0 extensions are installed on this device and run only while EdgeEver is open. Background schedules are not included yet.",
noticeTitle: "Plugin",
panel: "Plugin panel",
panelDescription: "This interface is provided by an enabled trusted plugin.",
sources: {
verified: "Verified",
github: "GitHub sideload",
manifest: "Manifest sideload",
marketplace: "Marketplace install",
},
marketplace: {
title: "Plugin marketplace",
description: "The marketplace is a verified index; plugin files remain distributed by developers through GitHub or public URLs.",
refresh: "Refresh plugin marketplace",
loading: "Loading plugin marketplace…",
verified: "Verified by EdgeEver",
installed: "Verified version installed",
installVerified: "Replace with verified version",
openRepository: "Open the source repository for {{name}}",
},
toolbar: {
open: "Plugins",
recent: "Recently used",
empty: "Enabled plugins have not provided any commands or panels yet.",
manage: "Manage plugins and themes",
},
},
objectStorage: {
title: "OSS object storage",
description: "Configure the object storage used for new images and attachments across this EdgeEver instance. Only the instance owner can make changes.",
+38
View File
@@ -258,6 +258,44 @@ export const zhCN = {
account: "登录设置",
},
},
plugins: {
title: "插件与主题",
manifestUrl: "扩展 Manifest 地址",
manifestPlaceholder: "https://example.com/edgeever-plugin/manifest.json",
installSource: "GitHub 仓库或 Manifest 地址",
sourcePlaceholder: "https://github.com/owner/edgeever-plugin",
install: "安装",
installing: "安装中…",
empty: "当前设备尚未安装插件或主题。",
toggle: "启用或停用 {{name}}",
uninstall: "卸载",
localOnly: "P0 扩展仅安装在当前设备,并且只在 EdgeEver 打开期间运行;暂不包含后台定时任务。",
noticeTitle: "插件",
panel: "插件面板",
panelDescription: "此界面由已启用的受信任插件提供。",
sources: {
verified: "已验证",
github: "GitHub 自由安装",
manifest: "Manifest 自由安装",
marketplace: "市场安装",
},
marketplace: {
title: "插件市场",
description: "市场只提供经过校验的索引,插件仍由开发者通过 GitHub 或公开地址分发。",
refresh: "刷新插件市场",
loading: "正在加载插件市场…",
verified: "EdgeEver 已验证",
installed: "已安装验证版本",
installVerified: "替换为验证版本",
openRepository: "打开 {{name}} 的源代码仓库",
},
toolbar: {
open: "插件",
recent: "最近使用",
empty: "已启用的插件还没有提供命令或面板。",
manage: "管理插件与主题",
},
},
objectStorage: {
title: "OSS 对象存储",
description: "配置整个 EdgeEver 实例用于新图片和附件的对象存储。仅实例 Owner 可以修改。",