feat(plugins): sync the install list across Web and desktop

Keep plugin and theme membership on the workspace so browsers and desktop apps can re-download verified packages. Settings, secrets, and native mobile runtimes stay out of this catalog.

Related Issue: #396
This commit is contained in:
tianma-if committed 2026-09-14 10:41:56 +08:00
1 parent 7e8eadae72
commit 0e5be33a44
20 files changed
+1177 -26

No files matched your search

+1 -1
View File
@@ -167,7 +167,7 @@ Welcome to the EdgeEver community. Join us to discuss the EdgeEver experience, r
## Plugins and Themes
EdgeEver supports device-local plugins and code-free themes on Web and desktop, installable from the Plugin Marketplace, GitHub, or a Manifest URL. The official marketplace only lists free and open-source plugins; this requirement does not apply to direct installation from GitHub or a Manifest URL. Developers can use `@edgeever/plugin-api`; see the [plugin development guide](docs/plugin-development.md) and [marketplace submission policy](docs/plugin-marketplace-policy.md).
EdgeEver supports plugins and code-free themes on Web and desktop, installable from the Plugin Marketplace, GitHub, or a Manifest URL. The install list follows the current workspace across browsers and desktop apps; each client downloads and verifies packages locally. Native Android and iOS apps do not run plugins. Settings and secrets stay on the current device. The official marketplace only lists free and open-source plugins; this requirement does not apply to direct installation from GitHub or a Manifest URL. Developers can use `@edgeever/plugin-api`; see the [plugin development guide](docs/plugin-development.md) and [marketplace submission policy](docs/plugin-marketplace-policy.md).
## Tech Stack
+1 -1
View File
@@ -165,7 +165,7 @@ EdgeEver 官方容器镜像托管于 GitHub Container Registry(GHCR)。部
## 插件与主题
EdgeEver 的 Web 与桌面端支持安装在当前设备的插件和无代码主题,可从插件市场、GitHub 或 Manifest 地址安装。官方插件市场仅收录自由及开源插件,该要求不限制用户直接通过 GitHub 或 Manifest 地址安装其他插件。开发者可使用 `@edgeever/plugin-api`,详情参阅[插件开发文档](docs/plugin-development.zh-CN.md)和[官方插件市场上架政策](docs/plugin-marketplace-policy.zh-CN.md)。
EdgeEver 的 Web 与桌面端支持插件和无代码主题,可从插件市场、GitHub 或 Manifest 地址安装。安装清单会随当前工作区在浏览器和桌面应用之间同步,每个客户端会自行下载并校验插件包;Android 和 iOS 原生应用不运行插件。设置和密钥仍只保存在当前设备。官方插件市场仅收录自由及开源插件,该要求不限制用户直接通过 GitHub 或 Manifest 地址安装其他插件。开发者可使用 `@edgeever/plugin-api`,详情参阅[插件开发文档](docs/plugin-development.zh-CN.md)和[官方插件市场上架政策](docs/plugin-marketplace-policy.zh-CN.md)。
## 技术栈
+2
View File
@@ -90,6 +90,7 @@ import { registerPluginDistributionRoutes } from "./plugin-distribution-routes";
import { registerSyncRoutes } from "./sync-routes";
import { registerMemoRoutes } from "./memo-routes";
import { registerScheduledTaskRoutes } from "./scheduled-task-routes";
import { registerWorkspaceExtensionRoutes } from "./workspace-extension-routes";
import { registerBackupRoutes } from "./backup-routes";
import { registerMcpRoutes } from "./mcp-routes";
import { executeWorkspaceTool } from "./mcp-tool-executor";
@@ -327,6 +328,7 @@ registerSyncRoutes(app, {
registerTagRoutes(app);
registerPluginDistributionRoutes(app);
registerScheduledTaskRoutes(app);
registerWorkspaceExtensionRoutes(app, { isDemoMode: (...args) => isDemoMode(...args) });
registerMemoShareRoutes(app);
registerTemplateRoutes(app, {
createMemoRecord: (...args) => createMemoRecord(...args),
@@ -0,0 +1,162 @@
import { describe, expect, test } from "bun:test";
import { globSync, readFileSync } from "node:fs";
import { Database } from "bun:sqlite";
import { Hono } from "hono";
import { registerWorkspaceExtensionRoutes } from "./workspace-extension-routes.ts";
const auth = {
kind: "user",
actorType: "user",
actorId: "usr_owner",
username: "owner",
displayName: "Owner",
scopes: [],
workspaceId: "ws_extensions",
role: "owner",
};
class SqlitePreparedStatement {
constructor(db, sql, bindings = []) {
this.db = db;
this.sql = sql;
this.bindings = bindings;
}
bind(...bindings) { return new SqlitePreparedStatement(this.db, this.sql, bindings); }
async all() { return { results: this.db.query(this.sql).all(...this.bindings), success: true, meta: {} }; }
async first() { return this.db.query(this.sql).get(...this.bindings) ?? null; }
async run() {
this.db.query(this.sql).run(...this.bindings);
return { results: [], success: true, meta: {} };
}
}
class SqliteDatabase {
constructor(db) { this.db = db; }
prepare(sql) { return new SqlitePreparedStatement(this.db, sql); }
async batch(statements) {
return this.db.transaction(() => statements.map((statement) =>
this.db.query(statement.sql).run(...statement.bindings)))();
}
}
const createFixture = ({ demoMode = false } = {}) => {
const sqlite = new Database(":memory:");
for (const migration of globSync("migrations/*.sql").sort()) {
sqlite.exec(readFileSync(migration, "utf8"));
}
sqlite.query("INSERT INTO workspaces (id, name, is_personal) VALUES (?, ?, 1)")
.run(auth.workspaceId, "Extension workspace");
const app = new Hono();
app.use("/api/v1/*", async (context, next) => {
context.set("auth", auth);
await next();
});
registerWorkspaceExtensionRoutes(app, { isDemoMode: () => demoMode });
return {
app,
environment: { storage: { db: new SqliteDatabase(sqlite), resources: {} } },
};
};
const pluginBody = {
type: "plugin",
version: "1.2.0",
enabled: true,
installedAt: "2026-09-13T12:00:00.000Z",
manifestUrl: "https://github.com/example/recent-notes/releases/download/v1.2.0/manifest.json",
sourceKind: "github",
verified: false,
repositoryUrl: "https://github.com/example/recent-notes",
releaseTag: "v1.2.0",
};
describe("workspace extension catalog routes", () => {
test("upserts the install list, tombstones uninstalls, and restores a later install", async () => {
const { app, environment } = createFixture();
const created = await app.request("/api/v1/workspace-extensions/com.example.recent-notes", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify(pluginBody),
}, environment);
expect(created.status).toBe(200);
const createdExtension = (await created.json()).extension;
expect(createdExtension).toMatchObject({
extensionId: "com.example.recent-notes",
type: "plugin",
version: "1.2.0",
enabled: true,
deletedAt: null,
sourceKind: "github",
repositoryUrl: "https://github.com/example/recent-notes",
});
const listed = await app.request("/api/v1/workspace-extensions", {}, environment);
expect((await listed.json()).extensions).toHaveLength(1);
const removed = await app.request("/api/v1/workspace-extensions/com.example.recent-notes", {
method: "DELETE",
}, environment);
expect(removed.status).toBe(200);
const tombstone = (await removed.json()).extension;
expect(tombstone.deletedAt).toBeTruthy();
expect(tombstone.enabled).toBe(false);
const listedAfterDelete = await app.request("/api/v1/workspace-extensions", {}, environment);
expect((await listedAfterDelete.json()).extensions[0].deletedAt).toBeTruthy();
const restored = await app.request("/api/v1/workspace-extensions/com.example.recent-notes", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ ...pluginBody, version: "1.3.0", enabled: false }),
}, environment);
expect((await restored.json()).extension).toMatchObject({
version: "1.3.0",
enabled: false,
deletedAt: null,
});
});
test("keeps only one enabled theme in the workspace catalog", async () => {
const { app, environment } = createFixture();
const theme = (name, enabled) => ({
type: "theme",
version: "1.0.0",
enabled,
installedAt: "2026-09-13T12:00:00.000Z",
manifestUrl: `https://example.test/${name}/manifest.json`,
sourceKind: "manifest",
verified: false,
});
await app.request("/api/v1/workspace-extensions/com.example.theme-one", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify(theme("theme-one", true)),
}, environment);
await app.request("/api/v1/workspace-extensions/com.example.theme-two", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify(theme("theme-two", true)),
}, environment);
const listed = await app.request("/api/v1/workspace-extensions", {}, environment);
const extensions = (await listed.json()).extensions;
const enabled = extensions.filter((item) => item.enabled).map((item) => item.extensionId);
expect(enabled).toEqual(["com.example.theme-two"]);
expect(extensions.find((item) => item.extensionId === "com.example.theme-one").enabled).toBe(false);
});
test("rejects demo workspaces and invalid extension ids", async () => {
const demo = createFixture({ demoMode: true });
const demoResponse = await demo.app.request("/api/v1/workspace-extensions", {}, demo.environment);
expect(demoResponse.status).toBe(403);
const { app, environment } = createFixture();
const invalid = await app.request("/api/v1/workspace-extensions/invalid", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify(pluginBody),
}, environment);
expect(invalid.status).toBe(400);
});
});
+209
View File
@@ -0,0 +1,209 @@
import {
WorkspaceExtensionUpsertSchema,
type WorkspaceExtension,
type WorkspaceExtensionSourceKind,
} from "@edgeever/shared";
import { zValidator } from "@hono/zod-validator";
import type { Hono } from "hono";
import { audit } from "./audit";
import type { AppEnv, Bindings } from "./api-context";
import { isoNow } from "./entity-utils";
import { apiError, badRequest, notFound } from "./http-errors";
import { getAuditActor, getWorkspaceId, requireUser } from "./request-auth";
import type { DatabaseAdapter } from "./storage-contract";
const EXTENSION_ID_PATTERN = /^[a-z0-9]+(?:[._-][a-z0-9]+)+$/;
const MAX_WORKSPACE_EXTENSIONS = 100;
type WorkspaceExtensionRow = {
extension_id: string;
extension_type: "plugin" | "theme";
version: string;
enabled: number;
source_kind: WorkspaceExtensionSourceKind;
verified: number;
manifest_url: string;
repository_url: string | null;
release_tag: string | null;
publisher: "edgeever" | null;
installed_at: string;
updated_at: string;
deleted_at: string | null;
};
const EXTENSION_SELECT = `SELECT extension_id, extension_type, version, enabled, source_kind, verified,
manifest_url, repository_url, release_tag, publisher, installed_at, updated_at, deleted_at
FROM workspace_extensions`;
export const mapWorkspaceExtensionRow = (row: WorkspaceExtensionRow): WorkspaceExtension => ({
extensionId: row.extension_id,
type: row.extension_type,
version: row.version,
enabled: row.enabled === 1,
installedAt: row.installed_at,
updatedAt: row.updated_at,
deletedAt: row.deleted_at,
manifestUrl: row.manifest_url,
sourceKind: row.source_kind,
verified: row.verified === 1,
repositoryUrl: row.repository_url,
releaseTag: row.release_tag,
publisher: row.publisher === "edgeever" ? "edgeever" : null,
});
const getWorkspaceExtensionRow = async (
db: DatabaseAdapter,
workspaceId: string,
extensionId: string,
) =>
db.prepare(`${EXTENSION_SELECT} WHERE workspace_id = ? AND extension_id = ?`)
.bind(workspaceId, extensionId)
.first<WorkspaceExtensionRow>();
const countLiveWorkspaceExtensions = async (
db: DatabaseAdapter,
workspaceId: string,
exceptExtensionId?: string,
) => {
const row = exceptExtensionId
? await db.prepare(
`SELECT COUNT(*) AS total FROM workspace_extensions
WHERE workspace_id = ? AND deleted_at IS NULL AND extension_id != ?`,
).bind(workspaceId, exceptExtensionId).first<{ total: number }>()
: await db.prepare(
`SELECT COUNT(*) AS total FROM workspace_extensions
WHERE workspace_id = ? AND deleted_at IS NULL`,
).bind(workspaceId).first<{ total: number }>();
return Number(row?.total ?? 0);
};
const parseExtensionId = (value: string) => {
const extensionId = value.trim();
return EXTENSION_ID_PATTERN.test(extensionId) && extensionId.length <= 200 ? extensionId : null;
};
export const registerWorkspaceExtensionRoutes = (
app: Hono<AppEnv>,
dependencies: { isDemoMode: (env: Bindings) => boolean },
) => {
app.use("/api/v1/workspace-extensions/*", async (c, next) => {
const denied = requireUser(c);
if (denied) return denied;
if (dependencies.isDemoMode(c.env)) {
return apiError(c, "workspace_extension_demo_disabled", "Plugin catalog sync is not available in the public demo.", 403);
}
await next();
});
app.use("/api/v1/workspace-extensions", async (c, next) => {
const denied = requireUser(c);
if (denied) return denied;
if (dependencies.isDemoMode(c.env)) {
return apiError(c, "workspace_extension_demo_disabled", "Plugin catalog sync is not available in the public demo.", 403);
}
await next();
});
app.get("/api/v1/workspace-extensions", async (c) => {
const rows = await c.env.storage.db.prepare(
`${EXTENSION_SELECT} WHERE workspace_id = ? ORDER BY updated_at DESC, extension_id ASC`,
).bind(getWorkspaceId(c)).all<WorkspaceExtensionRow>();
return c.json({ extensions: rows.results.map(mapWorkspaceExtensionRow) });
});
app.put("/api/v1/workspace-extensions/:extensionId", zValidator("json", WorkspaceExtensionUpsertSchema), async (c) => {
const extensionId = parseExtensionId(c.req.param("extensionId"));
if (!extensionId) return badRequest(c, "Extension id is invalid.");
const input = c.req.valid("json");
const workspaceId = getWorkspaceId(c);
const existing = await getWorkspaceExtensionRow(c.env.storage.db, workspaceId, extensionId);
if (!existing || existing.deleted_at) {
const liveCount = await countLiveWorkspaceExtensions(
c.env.storage.db,
workspaceId,
existing?.deleted_at ? extensionId : undefined,
);
if (liveCount >= MAX_WORKSPACE_EXTENSIONS) {
return apiError(c, "workspace_extension_limit", "This workspace already has the maximum number of synced extensions.", 400);
}
}
const now = isoNow();
const repositoryUrl = input.repositoryUrl ?? null;
const releaseTag = input.releaseTag ?? null;
const publisher = input.publisher ?? null;
await c.env.storage.db.prepare(
`INSERT INTO workspace_extensions (
workspace_id, extension_id, extension_type, version, enabled, source_kind, verified,
manifest_url, repository_url, release_tag, publisher, installed_at, updated_at, deleted_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)
ON CONFLICT(workspace_id, extension_id) DO UPDATE SET
extension_type = excluded.extension_type,
version = excluded.version,
enabled = excluded.enabled,
source_kind = excluded.source_kind,
verified = excluded.verified,
manifest_url = excluded.manifest_url,
repository_url = excluded.repository_url,
release_tag = excluded.release_tag,
publisher = excluded.publisher,
installed_at = excluded.installed_at,
updated_at = excluded.updated_at,
deleted_at = NULL`,
).bind(
workspaceId,
extensionId,
input.type,
input.version,
input.enabled ? 1 : 0,
input.sourceKind,
input.verified ? 1 : 0,
input.manifestUrl,
repositoryUrl,
releaseTag,
publisher,
input.installedAt,
now,
).run();
if (input.type === "theme" && input.enabled) {
await c.env.storage.db.prepare(
`UPDATE workspace_extensions
SET enabled = 0, updated_at = ?
WHERE workspace_id = ? AND extension_type = 'theme' AND extension_id != ? AND deleted_at IS NULL AND enabled = 1`,
).bind(now, workspaceId, extensionId).run();
}
const row = await getWorkspaceExtensionRow(c.env.storage.db, workspaceId, extensionId);
if (!row) return notFound(c, "Workspace extension not found");
const actor = getAuditActor(c);
await audit(c.env.storage.db, actor.actorType, actor.actorId, "workspace_extension.upsert", "workspace_extension", extensionId, {
type: input.type,
version: input.version,
enabled: input.enabled,
sourceKind: input.sourceKind,
});
return c.json({ extension: mapWorkspaceExtensionRow(row) });
});
app.delete("/api/v1/workspace-extensions/:extensionId", async (c) => {
const extensionId = parseExtensionId(c.req.param("extensionId"));
if (!extensionId) return badRequest(c, "Extension id is invalid.");
const workspaceId = getWorkspaceId(c);
const existing = await getWorkspaceExtensionRow(c.env.storage.db, workspaceId, extensionId);
if (!existing) return notFound(c, "Workspace extension not found");
if (existing.deleted_at) return c.json({ extension: mapWorkspaceExtensionRow(existing) });
const now = isoNow();
await c.env.storage.db.prepare(
`UPDATE workspace_extensions
SET enabled = 0, deleted_at = ?, updated_at = ?
WHERE workspace_id = ? AND extension_id = ?`,
).bind(now, now, workspaceId, extensionId).run();
const row = await getWorkspaceExtensionRow(c.env.storage.db, workspaceId, extensionId);
const actor = getAuditActor(c);
await audit(c.env.storage.db, actor.actorType, actor.actorId, "workspace_extension.delete", "workspace_extension", extensionId, {
type: existing.extension_type,
});
return c.json({ extension: row ? mapWorkspaceExtensionRow(row) : mapWorkspaceExtensionRow({ ...existing, enabled: 0, deleted_at: now, updated_at: now }) });
});
};
+44 -3
View File
@@ -35,6 +35,12 @@ import { api, getOrCreateClientDeviceId } from "@/lib/api";
import { MarkdownExportMemoryLimitError, type MarkdownExportProgress } from "@/lib/markdown-export";
import { exportSelectedMemosAsMarkdownZip } from "@/lib/selected-markdown-export";
import { createPluginScheduleAdapter } from "@/lib/plugins/plugin-schedule-adapter";
import { createPluginCatalogAdapter } from "@/lib/plugins/plugin-catalog-sync";
import {
acknowledgePluginTrustWarning,
hasAcknowledgedPluginTrustWarning,
PLUGIN_TRUST_WARNING_COPY,
} from "@/lib/plugins/plugin-trust";
import {
clearMobileEditorReturnPreview,
consumeStandaloneMobileEditorReturn,
@@ -756,6 +762,7 @@ export const WorkspaceApp = ({
const [notebookNameDialog, setNotebookNameDialog] = useState<NotebookNameDialogState | null>(null);
const [notebookDeleteConfirmation, setNotebookDeleteConfirmation] = useState<Notebook | null>(null);
const [appNoticeDialog, setAppNoticeDialog] = useState<AppNoticeDialogState | null>(null);
const [pendingCatalogTrustPluginIds, setPendingCatalogTrustPluginIds] = useState<string[]>([]);
const [isExportingSelectedMemos, setIsExportingSelectedMemos] = useState(false);
const [selectedMarkdownExportProgress, setSelectedMarkdownExportProgress] = useState<MarkdownExportProgress>({
completed: 0,
@@ -773,6 +780,7 @@ export const WorkspaceApp = ({
const pluginPublicNetworkAdapter = useMemo(() => createPublicNetworkAdapter(api.pluginNetwork, {
desktop: window.edgeeverDesktop?.isAvailable ? window.edgeeverDesktop : undefined,
}), []);
const pluginCatalogAdapter = useMemo(() => demoMode ? undefined : createPluginCatalogAdapter(), [demoMode]);
const pluginHost = useMemo(() => new EdgeEverPluginHost({
repository,
scope: localDataScope,
@@ -780,6 +788,7 @@ export const WorkspaceApp = ({
publicNetworkAdapter: pluginPublicNetworkAdapter,
onNotice: (message) => setAppNoticeDialog({ title: t("plugins.noticeTitle"), description: message }),
scheduleAdapter: pluginScheduleAdapter,
catalogAdapter: pluginCatalogAdapter,
onWorkspaceChanged: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["memos"] }),
@@ -790,14 +799,16 @@ export const WorkspaceApp = ({
queryClient.invalidateQueries({ queryKey: ["resources"] }),
]);
},
}), [localDataScope, pluginPublicNetworkAdapter, pluginScheduleAdapter, queryClient, repository, t]);
}), [localDataScope, pluginCatalogAdapter, pluginPublicNetworkAdapter, pluginScheduleAdapter, queryClient, repository, t]);
const [pluginHostReady, setPluginHostReady] = useState(false);
const pluginHostSnapshot = useSyncExternalStore(pluginHost.subscribe, pluginHost.getSnapshot, pluginHost.getSnapshot);
useEffect(() => {
let active = true;
setPluginHostReady(false);
void pluginHost.activateEnabled().then(() => {
if (active) setPluginHostReady(true);
void pluginHost.activateEnabled().then((pendingTrustPluginIds) => {
if (!active) return;
setPluginHostReady(true);
if (pendingTrustPluginIds.length > 0) setPendingCatalogTrustPluginIds(pendingTrustPluginIds);
}).catch(() => {
if (active) setPluginHostReady(false);
});
@@ -814,6 +825,10 @@ export const WorkspaceApp = ({
if (!active || running) return;
running = true;
try {
const pendingTrustPluginIds = await pluginHost.syncFromCatalog();
if (active && pendingTrustPluginIds.length > 0 && !hasAcknowledgedPluginTrustWarning()) {
setPendingCatalogTrustPluginIds(pendingTrustPluginIds);
}
const marketplace = await loadResolvedPluginMarketplace();
await updateOfficialMarketplacePlugins(pluginHost, marketplace.entries);
const firstResolutionError = Object.entries(marketplace.resolutionErrors)[0];
@@ -1178,6 +1193,7 @@ export const WorkspaceApp = ({
const mobileSearchActive = mobileBottomNavActive === "search";
const workspaceBackTargetActive = Boolean(
appNoticeDialog ||
pendingCatalogTrustPluginIds.length > 0 ||
notebookDeleteConfirmation ||
notebookNameDialog ||
memoDeleteConfirmation ||
@@ -1198,6 +1214,7 @@ export const WorkspaceApp = ({
!isDesktop &&
visibleActivePane === "memos" &&
!appNoticeDialog &&
pendingCatalogTrustPluginIds.length === 0 &&
!notebookDeleteConfirmation &&
!notebookNameDialog &&
!memoDeleteConfirmation &&
@@ -2839,6 +2856,11 @@ export const WorkspaceApp = ({
return true;
}
if (pendingCatalogTrustPluginIds.length > 0) {
setPendingCatalogTrustPluginIds([]);
return true;
}
if (notebookDeleteConfirmation) {
if (!deleteNotebookMutation.isPending) {
setNotebookDeleteConfirmation(null);
@@ -2948,6 +2970,7 @@ export const WorkspaceApp = ({
visibleActivePane,
desktopFocusModeActive,
appNoticeDialog,
pendingCatalogTrustPluginIds,
rightView,
clearMemoSelection,
createNotebookMutation.isPending,
@@ -3027,6 +3050,7 @@ export const WorkspaceApp = ({
const transientLayerOpen = Boolean(
appNoticeDialog ||
pendingCatalogTrustPluginIds.length > 0 ||
rightView !== "editor" ||
memoDeleteConfirmation ||
emptyTrashConfirmationOpen ||
@@ -3130,6 +3154,7 @@ export const WorkspaceApp = ({
}, [
rightView,
appNoticeDialog,
pendingCatalogTrustPluginIds,
canCreateMemo,
clearPendingCreatedMemo,
clearMemoSelection,
@@ -3792,6 +3817,22 @@ export const WorkspaceApp = ({
onConfirm={() => setAppNoticeDialog(null)}
/>
)}
{pendingCatalogTrustPluginIds.length > 0 ? (
<AppConfirmDialog
title={t(PLUGIN_TRUST_WARNING_COPY.titleKey)}
description={t(PLUGIN_TRUST_WARNING_COPY.descriptionKey)}
confirmLabel={t(PLUGIN_TRUST_WARNING_COPY.confirmLabelKey)}
closeOnBrowserBack={false}
tone="neutral"
onCancel={() => setPendingCatalogTrustPluginIds([])}
onConfirm={() => {
const pluginIds = pendingCatalogTrustPluginIds;
acknowledgePluginTrustWarning();
setPendingCatalogTrustPluginIds([]);
void Promise.all(pluginIds.map((pluginId) => pluginHost.setEnabled(pluginId, true).catch(() => undefined)));
}}
/>
) : null}
{demoResetConfirmationOpen && (
<AppConfirmDialog
title={t("demo.resetTitle")}
@@ -401,14 +401,19 @@ export const PluginManagerCard = ({
return (
<Card className="w-full min-w-0 shadow-none">
<CardHeader className="p-4 sm:p-5">
<div className="flex items-center justify-between gap-3">
<CardTitle className="flex items-center gap-2 text-sm">
<Puzzle className="h-4 w-4 text-emerald-700" />
{selectedPluginId ? t("plugins.details.title") : t("plugins.title")}
<span className="inline-flex items-center rounded-full border border-emerald-200/80 bg-emerald-50 px-2 py-0.5 text-[10px] font-semibold tracking-wide text-emerald-700 ">
Beta
</span>
</CardTitle>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<CardTitle className="flex items-center gap-2 text-sm">
<Puzzle className="h-4 w-4 text-emerald-700" />
{selectedPluginId ? t("plugins.details.title") : t("plugins.title")}
<span className="inline-flex items-center rounded-full border border-emerald-200/80 bg-emerald-50 px-2 py-0.5 text-[10px] font-semibold tracking-wide text-emerald-700 ">
Beta
</span>
</CardTitle>
{selectedPluginId ? null : (
<p className="mt-1 text-xs leading-5 text-slate-500">{t("plugins.syncDescription")}</p>
)}
</div>
<div className="flex items-center gap-1">
{snapshot.extensions.length > 0 || (marketplaceQuery.data?.entries.length ?? 0) > 0 ? (
<Button
@@ -0,0 +1,144 @@
import { afterAll, expect, test } from "bun:test";
const originalWindow = globalThis.window;
const originalDocument = globalThis.document;
const originalMutationObserver = globalThis.MutationObserver;
const values = new Map();
globalThis.window = {
location: { href: "https://example.test" },
localStorage: {
getItem: (key) => values.get(key) ?? null,
setItem: (key, value) => values.set(key, String(value)),
removeItem: (key) => values.delete(key),
get length() { return values.size; },
key: (index) => [...values.keys()][index] ?? null,
},
addEventListener() {},
removeEventListener() {},
};
globalThis.document = {
documentElement: {
classList: { contains: () => false },
dataset: {},
style: { setProperty() {}, removeProperty() {} },
removeAttribute() {},
},
};
globalThis.MutationObserver = class { observe() {} disconnect() {} };
const { EdgeEverPluginHost } = await import("./plugin-host.ts");
afterAll(() => {
globalThis.window = originalWindow;
globalThis.document = originalDocument;
globalThis.MutationObserver = originalMutationObserver;
});
const secretStorage = { get: async () => null, set: async () => {}, remove: async () => {}, clearNamespace: async () => {} };
const pluginManifest = (id) => ({
type: "plugin",
id,
name: "Catalog sync",
version: "1.0.0",
apiVersion: "2",
settingsUi: "host",
entry: new URL("./plugin-lifecycle.fixture.mjs", import.meta.url).href,
permissions: ["notes:read", "ui:commands", "ui:panels"],
});
test("enable and uninstall update the workspace catalog", async () => {
values.clear();
const upserts = [];
const removals = [];
const host = new EdgeEverPluginHost({
scope: "catalog-host",
packageStorage: { get: async () => null, put: async () => {}, remove: async () => {} },
secretStorage,
repository: { listMemos: async () => ({ memos: [], totalCount: 0, nextCursor: null }) },
catalogAdapter: {
list: async () => [],
upsert: async (extensionId, input) => {
upserts.push({ extensionId, input });
return {
extensionId,
...input,
repositoryUrl: input.repositoryUrl ?? null,
releaseTag: input.releaseTag ?? null,
publisher: input.publisher ?? null,
updatedAt: "2026-09-14T10:00:00.000Z",
deletedAt: null,
};
},
remove: async (extensionId) => {
removals.push(extensionId);
return {
extensionId,
type: "plugin",
version: "1.0.0",
enabled: false,
installedAt: "2026-09-14T09:00:00.000Z",
updatedAt: "2026-09-14T10:00:00.000Z",
deletedAt: "2026-09-14T10:00:00.000Z",
manifestUrl: "https://example.test/manifest.json",
sourceKind: "manifest",
verified: false,
repositoryUrl: null,
releaseTag: null,
publisher: null,
};
},
},
});
const id = "org.edgeever.catalog-sync";
host.installManifest(pluginManifest(id), "https://example.test/manifest.json");
await host.setEnabled(id, true);
expect(upserts.at(-1)).toMatchObject({ extensionId: id, input: { enabled: true, version: "1.0.0" } });
await host.uninstall(id);
expect(removals).toEqual([id]);
await host.dispose();
});
test("a remote tombstone uninstalls the local extension without writing the catalog again", async () => {
values.clear();
const upserts = [];
const removals = [];
const id = "org.edgeever.catalog-tombstone";
const host = new EdgeEverPluginHost({
scope: "catalog-tombstone",
packageStorage: { get: async () => null, put: async () => {}, remove: async () => {} },
secretStorage,
repository: { listMemos: async () => ({ memos: [], totalCount: 0, nextCursor: null }) },
catalogAdapter: {
list: async () => [{
extensionId: id,
type: "plugin",
version: "1.0.0",
enabled: false,
installedAt: "2026-09-13T10:00:00.000Z",
updatedAt: "2026-09-14T12:00:00.000Z",
deletedAt: "2026-09-14T12:00:00.000Z",
manifestUrl: "https://example.test/manifest.json",
sourceKind: "manifest",
verified: false,
repositoryUrl: null,
releaseTag: null,
publisher: null,
}],
upsert: async (extensionId, input) => {
upserts.push({ extensionId, input });
throw new Error("tombstone reconcile must not upsert");
},
remove: async (extensionId) => {
removals.push(extensionId);
throw new Error("tombstone reconcile must not delete again");
},
},
});
host.installManifest(pluginManifest(id), "https://example.test/manifest.json");
await host.syncFromCatalog();
expect(host.getSnapshot().extensions.find((item) => item.manifest.id === id)).toBeUndefined();
expect(upserts).toEqual([]);
expect(removals).toEqual([]);
await host.dispose();
});
@@ -0,0 +1,118 @@
import { describe, expect, test } from "bun:test";
import { planCatalogReconcile } from "./plugin-catalog-sync.ts";
const remote = ({
extensionId = "com.example.tasks",
type = "plugin",
version = "1.0.0",
enabled = true,
updatedAt = "2026-09-14T10:00:00.000Z",
deletedAt = null,
publisher = null,
} = {}) => ({
extensionId,
type,
version,
enabled,
installedAt: "2026-09-13T10:00:00.000Z",
updatedAt,
deletedAt,
manifestUrl: "https://github.com/example/tasks/releases/download/v1.0.0/manifest.json",
sourceKind: "github",
verified: false,
repositoryUrl: "https://github.com/example/tasks",
releaseTag: "v1.0.0",
publisher,
});
const local = ({
extensionId = "com.example.tasks",
type = "plugin",
version = "1.0.0",
enabled = false,
catalogUpdatedAt = "2026-09-13T10:00:00.000Z",
publisher,
} = {}) => ({
extensionId,
type,
version,
enabled,
catalogUpdatedAt,
...(publisher ? { publisher } : {}),
});
describe("planCatalogReconcile", () => {
test("seeds local-only extensions and installs remote-only extensions", () => {
expect(planCatalogReconcile({
local: [local({ extensionId: "com.example.local" })],
remote: [remote({ extensionId: "com.example.remote", publisher: "edgeever" })],
hasTrustAcknowledgement: false,
officialIds: new Set(["com.example.remote"]),
})).toEqual([
{ type: "install", entry: remote({ extensionId: "com.example.remote", publisher: "edgeever" }) },
{ type: "setEnabled", extensionId: "com.example.remote", enabled: true },
{ type: "push", extensionId: "com.example.local" },
]);
});
test("does not enable community plugins on a new device before trust is acknowledged", () => {
expect(planCatalogReconcile({
local: [],
remote: [remote()],
hasTrustAcknowledgement: false,
officialIds: new Set(),
})).toEqual([
{ type: "install", entry: remote() },
{ type: "awaitTrust", extensionId: "com.example.tasks" },
]);
});
test("enables community plugins after trust is acknowledged and skips themes", () => {
expect(planCatalogReconcile({
local: [],
remote: [remote(), remote({ extensionId: "com.example.nord", type: "theme" })],
hasTrustAcknowledgement: true,
officialIds: new Set(),
})).toEqual([
{ type: "install", entry: remote() },
{ type: "setEnabled", extensionId: "com.example.tasks", enabled: true },
{ type: "install", entry: remote({ extensionId: "com.example.nord", type: "theme" }) },
{ type: "setEnabled", extensionId: "com.example.nord", enabled: true },
]);
});
test("uninstalls tombstones unless the local catalog is newer", () => {
expect(planCatalogReconcile({
local: [local()],
remote: [remote({ deletedAt: "2026-09-14T11:00:00.000Z", updatedAt: "2026-09-14T11:00:00.000Z", enabled: false })],
hasTrustAcknowledgement: true,
officialIds: new Set(),
})).toEqual([{ type: "uninstall", extensionId: "com.example.tasks" }]);
expect(planCatalogReconcile({
local: [local({ catalogUpdatedAt: "2026-09-14T12:00:00.000Z" })],
remote: [remote({ deletedAt: "2026-09-14T11:00:00.000Z", updatedAt: "2026-09-14T11:00:00.000Z", enabled: false })],
hasTrustAcknowledgement: true,
officialIds: new Set(),
})).toEqual([{ type: "push", extensionId: "com.example.tasks" }]);
});
test("pushes a newer local change and applies a newer remote version", () => {
expect(planCatalogReconcile({
local: [local({ version: "1.1.0", enabled: true, catalogUpdatedAt: "2026-09-14T12:00:00.000Z" })],
remote: [remote({ version: "1.0.0", enabled: false, updatedAt: "2026-09-14T11:00:00.000Z" })],
hasTrustAcknowledgement: true,
officialIds: new Set(),
})).toEqual([{ type: "push", extensionId: "com.example.tasks" }]);
expect(planCatalogReconcile({
local: [local({ version: "1.0.0", enabled: true })],
remote: [remote({ version: "1.2.0", enabled: false, updatedAt: "2026-09-14T12:00:00.000Z" })],
hasTrustAcknowledgement: true,
officialIds: new Set(),
})).toEqual([
{ type: "install", entry: remote({ version: "1.2.0", enabled: false, updatedAt: "2026-09-14T12:00:00.000Z" }) },
{ type: "setEnabled", extensionId: "com.example.tasks", enabled: false },
]);
});
});
@@ -0,0 +1,150 @@
import type { WorkspaceExtension, WorkspaceExtensionUpsertInput } from "@edgeever/shared";
import { api } from "@/lib/api";
import type { ExtensionInstallSource, InstalledExtension } from "@/lib/plugins/plugin-host";
export interface PluginCatalogAdapter {
list(): Promise<WorkspaceExtension[]>;
upsert(extensionId: string, input: WorkspaceExtensionUpsertInput): Promise<WorkspaceExtension>;
remove(extensionId: string): Promise<WorkspaceExtension>;
}
export type LocalCatalogExtension = {
extensionId: string;
type: "plugin" | "theme";
version: string;
enabled: boolean;
catalogUpdatedAt: string;
publisher?: "edgeever";
};
export type CatalogReconcileAction =
| { type: "uninstall"; extensionId: string }
| { type: "install"; entry: WorkspaceExtension }
| { type: "setEnabled"; extensionId: string; enabled: boolean }
| { type: "awaitTrust"; extensionId: string }
| { type: "push"; extensionId: string };
export const createPluginCatalogAdapter = (): PluginCatalogAdapter => ({
list: async () => (await api.listWorkspaceExtensions()).extensions,
upsert: async (extensionId, input) => (await api.upsertWorkspaceExtension(extensionId, input)).extension,
remove: async (extensionId) => (await api.deleteWorkspaceExtension(extensionId)).extension,
});
export const toWorkspaceExtensionUpsert = (extension: InstalledExtension): WorkspaceExtensionUpsertInput => ({
type: extension.manifest.type,
version: extension.manifest.version,
enabled: extension.enabled,
installedAt: extension.installedAt,
manifestUrl: extension.manifestUrl,
sourceKind: extension.source.kind,
verified: extension.source.verified,
repositoryUrl: extension.source.repositoryUrl ?? null,
releaseTag: extension.source.releaseTag ?? null,
publisher: extension.source.publisher ?? null,
});
export const toLocalCatalogExtension = (extension: InstalledExtension): LocalCatalogExtension => ({
extensionId: extension.manifest.id,
type: extension.manifest.type,
version: extension.manifest.version,
enabled: extension.enabled,
catalogUpdatedAt: extension.catalogUpdatedAt,
...(extension.source.publisher === "edgeever" ? { publisher: "edgeever" } : {}),
});
const isOfficialExtension = (
extensionId: string,
type: "plugin" | "theme",
publisher: "edgeever" | null | undefined,
officialIds: ReadonlySet<string>,
) => type === "theme" || publisher === "edgeever" || officialIds.has(extensionId);
const enableAction = (
entry: Pick<WorkspaceExtension, "extensionId" | "type" | "publisher">,
hasTrustAcknowledgement: boolean,
officialIds: ReadonlySet<string>,
): CatalogReconcileAction => {
if (
entry.type === "plugin"
&& !hasTrustAcknowledgement
&& !isOfficialExtension(entry.extensionId, entry.type, entry.publisher, officialIds)
) {
return { type: "awaitTrust", extensionId: entry.extensionId };
}
return { type: "setEnabled", extensionId: entry.extensionId, enabled: true };
};
export const planCatalogReconcile = ({
local,
remote,
hasTrustAcknowledgement,
officialIds,
}: {
local: readonly LocalCatalogExtension[];
remote: readonly WorkspaceExtension[];
hasTrustAcknowledgement: boolean;
officialIds: ReadonlySet<string>;
}): CatalogReconcileAction[] => {
const actions: CatalogReconcileAction[] = [];
const localById = new Map(local.map((item) => [item.extensionId, item]));
const remoteById = new Map(remote.map((item) => [item.extensionId, item]));
for (const remoteEntry of remote) {
const localEntry = localById.get(remoteEntry.extensionId);
if (remoteEntry.deletedAt) {
if (localEntry && localEntry.catalogUpdatedAt > remoteEntry.updatedAt) {
actions.push({ type: "push", extensionId: localEntry.extensionId });
} else if (localEntry) {
actions.push({ type: "uninstall", extensionId: localEntry.extensionId });
}
continue;
}
if (!localEntry) {
actions.push({ type: "install", entry: remoteEntry });
if (remoteEntry.enabled) actions.push(enableAction(remoteEntry, hasTrustAcknowledgement, officialIds));
continue;
}
if (localEntry.catalogUpdatedAt > remoteEntry.updatedAt) {
actions.push({ type: "push", extensionId: localEntry.extensionId });
continue;
}
if (localEntry.version !== remoteEntry.version) {
actions.push({ type: "install", entry: remoteEntry });
}
const official = isOfficialExtension(
remoteEntry.extensionId,
remoteEntry.type,
remoteEntry.publisher ?? localEntry.publisher ?? null,
officialIds,
);
if (remoteEntry.enabled && !localEntry.enabled) {
actions.push(enableAction(
{ ...remoteEntry, publisher: official ? "edgeever" : remoteEntry.publisher },
hasTrustAcknowledgement,
officialIds,
));
} else if (!remoteEntry.enabled && localEntry.enabled) {
actions.push({ type: "setEnabled", extensionId: remoteEntry.extensionId, enabled: false });
}
}
for (const localEntry of local) {
if (!remoteById.has(localEntry.extensionId)) {
actions.push({ type: "push", extensionId: localEntry.extensionId });
}
}
return actions;
};
export const catalogInstallSource = (entry: WorkspaceExtension): ExtensionInstallSource => ({
kind: entry.sourceKind,
verified: entry.verified,
...(entry.repositoryUrl ? { repositoryUrl: entry.repositoryUrl } : {}),
...(entry.releaseTag ? { releaseTag: entry.releaseTag } : {}),
...(entry.publisher === "edgeever" ? { publisher: "edgeever" } : {}),
});
+158 -5
View File
@@ -39,7 +39,17 @@ 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, downloadPinnedGithubExtension, extensionManifestsEqual, parseGithubRepositoryUrl, sha256Hex } from "@/lib/plugins/github-plugin-distribution";
import {
catalogInstallSource,
planCatalogReconcile,
toLocalCatalogExtension,
toWorkspaceExtensionUpsert,
type PluginCatalogAdapter,
} from "@/lib/plugins/plugin-catalog-sync";
import { loadResolvedPluginMarketplace } from "@/lib/plugins/plugin-marketplace";
import { hasAcknowledgedPluginTrustWarning } from "@/lib/plugins/plugin-trust";
import { subscribeRepositoryMutations, type RepositoryMutationEvent } from "@/lib/repository-events";
import type { WorkspaceExtension } from "@edgeever/shared";
const INSTALLED_EXTENSIONS_STORAGE_KEY = "edgeever.extensions.installed.v1";
const ACTIVE_THEME_STORAGE_KEY = "edgeever.extensions.active-theme.v1";
@@ -109,6 +119,7 @@ export interface InstalledExtension {
manifest: ExtensionManifest;
enabled: boolean;
installedAt: string;
catalogUpdatedAt: string;
error: string | null;
source: ExtensionInstallSource;
}
@@ -118,6 +129,7 @@ export interface ExtensionInstallSource {
repositoryUrl?: string;
releaseTag?: string;
verified: boolean;
publisher?: "edgeever";
}
export interface RegisteredPluginCommand {
@@ -195,6 +207,7 @@ interface PluginHostOptions {
secretStorage?: PluginSecretStorage;
packageStorage?: PluginPackageStorage;
scheduleAdapter?: PluginScheduleAdapter;
catalogAdapter?: PluginCatalogAdapter;
}
interface ActivePlugin {
@@ -313,6 +326,7 @@ const normalizeInstallSource = (value: unknown): ExtensionInstallSource => {
verified: candidate.kind === "marketplace" && candidate.verified === true,
...(typeof candidate.repositoryUrl === "string" ? { repositoryUrl: candidate.repositoryUrl } : {}),
...(typeof candidate.releaseTag === "string" ? { releaseTag: candidate.releaseTag } : {}),
...(candidate.publisher === "edgeever" ? { publisher: "edgeever" } : {}),
};
};
@@ -325,11 +339,13 @@ const readInstalledExtensions = (): InstalledExtension[] => {
if (!item || typeof item !== "object") return [];
const candidate = item as Partial<InstalledExtension>;
if (typeof candidate.manifestUrl !== "string") return [];
const installedAt = typeof candidate.installedAt === "string" ? candidate.installedAt : new Date().toISOString();
return [{
manifestUrl: candidate.manifestUrl,
manifest: parseExtensionManifest(candidate.manifest),
enabled: Boolean(candidate.enabled),
installedAt: typeof candidate.installedAt === "string" ? candidate.installedAt : new Date().toISOString(),
installedAt,
catalogUpdatedAt: typeof candidate.catalogUpdatedAt === "string" ? candidate.catalogUpdatedAt : installedAt,
error: typeof candidate.error === "string" ? candidate.error : null,
source: normalizeInstallSource(candidate.source),
}];
@@ -422,6 +438,8 @@ export class EdgeEverPluginHost {
private readonly secretStorage: PluginSecretStorage;
private readonly packageStorage: PluginPackageStorage;
private readonly scheduleAdapter?: PluginScheduleAdapter;
private readonly catalogAdapter?: PluginCatalogAdapter;
private catalogSyncSuspended = 0;
private readonly listeners = new Set<() => void>();
private readonly activePlugins = new Map<string, ActivePlugin>();
private readonly commands = new Map<string, PluginCommand & { pluginId: string }>();
@@ -442,9 +460,9 @@ export class EdgeEverPluginHost {
private lifecycleQueue: Promise<void> = Promise.resolve();
private readonly activatingPlugins = new Map<string, Promise<void>>();
private enqueueLifecycle(action: () => Promise<void>) {
private enqueueLifecycle<T>(action: () => Promise<T>): Promise<T> {
const pending = this.lifecycleQueue.then(action, action);
this.lifecycleQueue = pending.catch(() => undefined);
this.lifecycleQueue = pending.then(() => undefined, () => undefined);
return pending;
}
@@ -458,6 +476,7 @@ export class EdgeEverPluginHost {
this.secretStorage = options.secretStorage ?? new WebPluginSecretStore();
this.packageStorage = options.packageStorage ?? new WebPluginPackageStore();
this.scheduleAdapter = options.scheduleAdapter;
this.catalogAdapter = options.catalogAdapter;
this.refreshSnapshot();
}
@@ -492,14 +511,102 @@ export class EdgeEverPluginHost {
activateEnabled() {
return this.enqueueLifecycle(async () => {
this.start();
const pendingTrustPluginIds = await this.syncFromCatalogOnce();
for (const extension of this.extensions) {
if (!extension.enabled || extension.manifest.type !== "plugin") continue;
await this.activatePlugin(extension.manifest.id).catch(() => undefined);
}
this.applyActiveTheme();
return pendingTrustPluginIds;
});
}
syncFromCatalog() {
return this.enqueueLifecycle(async () => this.syncFromCatalogOnce());
}
private async syncFromCatalogOnce() {
if (!this.catalogAdapter) return [] as string[];
let remote: WorkspaceExtension[];
try {
remote = await this.catalogAdapter.list();
} catch (error) {
console.error("Workspace extension catalog sync failed.", error);
return [];
}
let officialIds = new Set<string>();
try {
const marketplace = await loadResolvedPluginMarketplace();
officialIds = new Set(
marketplace.entries.filter((entry) => entry.publisher === "edgeever").map((entry) => entry.id),
);
} catch {
officialIds = new Set();
}
const actions = planCatalogReconcile({
local: this.extensions.map(toLocalCatalogExtension),
remote,
hasTrustAcknowledgement: hasAcknowledgedPluginTrustWarning(),
officialIds,
});
const pendingTrustPluginIds: string[] = [];
const failed = new Set<string>();
this.catalogSyncSuspended += 1;
try {
for (const action of actions) {
try {
if (action.type === "uninstall") {
await this.uninstall(action.extensionId);
continue;
}
if (action.type === "install") {
await this.installFromCatalogEntry(action.entry);
this.patchCatalogUpdatedAt(action.entry.extensionId, action.entry.updatedAt);
continue;
}
if (action.type === "setEnabled") {
if (failed.has(action.extensionId) || !this.extensions.some((item) => item.manifest.id === action.extensionId)) continue;
await this.setEnabled(action.extensionId, action.enabled);
const remoteEntry = remote.find((item) => item.extensionId === action.extensionId);
if (remoteEntry && !remoteEntry.deletedAt) this.patchCatalogUpdatedAt(action.extensionId, remoteEntry.updatedAt);
continue;
}
if (action.type === "awaitTrust") {
if (!failed.has(action.extensionId)) pendingTrustPluginIds.push(action.extensionId);
continue;
}
const extension = this.extensions.find((item) => item.manifest.id === action.extensionId);
if (extension) await this.pushCatalog(extension, { ignoreSuspend: true });
} catch (error) {
if (action.type === "install") failed.add(action.entry.extensionId);
console.error("Workspace extension catalog sync failed.", error);
}
}
} finally {
this.catalogSyncSuspended -= 1;
}
return pendingTrustPluginIds;
}
private async installFromCatalogEntry(entry: WorkspaceExtension) {
if ((entry.sourceKind === "github" || entry.sourceKind === "marketplace") && entry.repositoryUrl) {
const downloaded = await downloadPinnedGithubExtension(entry.repositoryUrl, entry.version);
return this.replaceInstalledExtension(
downloaded.manifest,
downloaded.manifestUrl,
catalogInstallSource({
...entry,
repositoryUrl: downloaded.repositoryUrl,
releaseTag: downloaded.releaseTag ?? entry.releaseTag,
}),
downloaded.pluginPackage,
);
}
return this.installFromManifestUrl(entry.manifestUrl);
}
async installFromSource(input: string) {
if (parseGithubRepositoryUrl(input)) return this.installFromGithubRepository(input);
return this.installFromManifestUrl(input);
@@ -519,6 +626,7 @@ export class EdgeEverPluginHost {
verified: Boolean(marketplaceEntry),
repositoryUrl: downloaded.repositoryUrl,
...(downloaded.releaseTag ? { releaseTag: downloaded.releaseTag } : {}),
...(marketplaceEntry?.publisher === "edgeever" ? { publisher: "edgeever" } : {}),
}, downloaded.pluginPackage);
}
@@ -571,6 +679,7 @@ export class EdgeEverPluginHost {
kind: marketplaceEntry ? "marketplace" : "manifest",
verified: Boolean(marketplaceEntry),
repositoryUrl: marketplaceEntry?.repositoryUrl,
...(marketplaceEntry?.publisher === "edgeever" ? { publisher: "edgeever" } : {}),
}, pluginPackage);
}
@@ -587,7 +696,9 @@ export class EdgeEverPluginHost {
const installed = this.installManifest(manifest, manifestUrl, source);
try {
if (installed.enabled && installed.manifest.type === "plugin") await this.activatePlugin(installed.manifest.id);
return installed;
this.markCatalogDirty(installed.manifest.id);
await this.pushCatalog(this.requireExtension(installed.manifest.id));
return this.requireExtension(installed.manifest.id);
} catch (error) {
if (previous) {
this.extensions = [...this.extensions.filter((item) => item.manifest.id !== previous.manifest.id), previous]
@@ -607,11 +718,13 @@ export class EdgeEverPluginHost {
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 installedAt = existing?.installedAt ?? new Date().toISOString();
const installed: InstalledExtension = {
manifestUrl,
manifest: normalizedManifest,
enabled: existing?.enabled ?? false,
installedAt: existing?.installedAt ?? new Date().toISOString(),
installedAt,
catalogUpdatedAt: existing?.catalogUpdatedAt ?? installedAt,
error: null,
source,
};
@@ -638,12 +751,16 @@ export class EdgeEverPluginHost {
}
this.applyActiveTheme();
this.persist();
this.markCatalogDirty(extensionId);
await this.pushCatalog(this.requireExtension(extensionId));
return;
}
if (enabled) {
this.extensions = this.extensions.map((item) => item.manifest.id === extensionId ? { ...item, enabled: true, error: null } : item);
this.persist();
this.markCatalogDirty(extensionId);
await this.pushCatalog(this.requireExtension(extensionId));
await this.activatePlugin(extensionId);
return;
}
@@ -651,6 +768,8 @@ export class EdgeEverPluginHost {
await this.deactivatePlugin(extensionId);
this.extensions = this.extensions.map((item) => item.manifest.id === extensionId ? { ...item, enabled: false, error: null } : item);
this.persist();
this.markCatalogDirty(extensionId);
await this.pushCatalog(this.requireExtension(extensionId));
}
async uninstall(extensionId: string) {
@@ -663,6 +782,7 @@ export class EdgeEverPluginHost {
this.applyActiveTheme();
}
this.persist();
await this.removeFromCatalog(extensionId);
await Promise.all([
this.packageStorage.remove(extensionId),
this.secretStorage.clearNamespace(`${this.scope}:${extensionId}`),
@@ -1524,6 +1644,39 @@ export class EdgeEverPluginHost {
this.persist();
}
private markCatalogDirty(extensionId: string) {
if (this.catalogSyncSuspended > 0) return;
const now = new Date().toISOString();
this.extensions = this.extensions.map((item) => item.manifest.id === extensionId ? { ...item, catalogUpdatedAt: now } : item);
this.persist();
}
private patchCatalogUpdatedAt(extensionId: string, catalogUpdatedAt: string) {
if (!this.extensions.some((item) => item.manifest.id === extensionId)) return;
this.extensions = this.extensions.map((item) => item.manifest.id === extensionId ? { ...item, catalogUpdatedAt } : item);
this.persist();
}
private async pushCatalog(extension: InstalledExtension, options?: { ignoreSuspend?: boolean }) {
if (!this.catalogAdapter) return;
if (this.catalogSyncSuspended > 0 && !options?.ignoreSuspend) return;
try {
const remote = await this.catalogAdapter.upsert(extension.manifest.id, toWorkspaceExtensionUpsert(extension));
this.patchCatalogUpdatedAt(extension.manifest.id, remote.updatedAt);
} catch (error) {
console.error("Workspace extension catalog sync failed.", error);
}
}
private async removeFromCatalog(extensionId: string) {
if (!this.catalogAdapter || this.catalogSyncSuspended > 0) return;
try {
await this.catalogAdapter.remove(extensionId);
} catch (error) {
console.error("Workspace extension catalog sync failed.", error);
}
}
private persist() {
writeStorageItem(INSTALLED_EXTENSIONS_STORAGE_KEY, JSON.stringify(this.extensions));
this.refreshSnapshot();
+71
View File
@@ -911,6 +911,42 @@
"responses": { "200": { "description": "Finished task run" }, "409": { "$ref": "#/components/responses/Error" } }
}
},
"/api/v1/workspace-extensions": {
"get": {
"summary": "List the workspace plugin and theme install catalog",
"responses": {
"200": {
"description": "Workspace extension catalog, including tombstones",
"content": { "application/json": { "schema": {
"type": "object",
"properties": { "extensions": { "type": "array", "items": { "$ref": "#/components/schemas/WorkspaceExtension" } } },
"required": ["extensions"]
} } }
},
"403": { "$ref": "#/components/responses/Error" }
}
}
},
"/api/v1/workspace-extensions/{extensionId}": {
"parameters": [{ "name": "extensionId", "in": "path", "required": true, "schema": { "type": "string" } }],
"put": {
"summary": "Create or update one workspace extension catalog entry",
"requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WorkspaceExtensionUpsertInput" } } } },
"responses": {
"200": { "description": "Upserted workspace extension", "content": { "application/json": { "schema": { "type": "object", "properties": { "extension": { "$ref": "#/components/schemas/WorkspaceExtension" } }, "required": ["extension"] } } } },
"400": { "$ref": "#/components/responses/Error" },
"403": { "$ref": "#/components/responses/Error" }
}
},
"delete": {
"summary": "Tombstone a workspace extension catalog entry",
"responses": {
"200": { "description": "Tombstoned workspace extension", "content": { "application/json": { "schema": { "type": "object", "properties": { "extension": { "$ref": "#/components/schemas/WorkspaceExtension" } }, "required": ["extension"] } } } },
"403": { "$ref": "#/components/responses/Error" },
"404": { "$ref": "#/components/responses/Error" }
}
}
},
"/api/v1/tags": {
"get": {
"summary": "List tag summaries",
@@ -2291,6 +2327,41 @@
},
"required": ["executorDeviceId", "status"]
},
"WorkspaceExtension": {
"type": "object",
"properties": {
"extensionId": { "type": "string" },
"type": { "type": "string", "enum": ["plugin", "theme"] },
"version": { "type": "string" },
"enabled": { "type": "boolean" },
"installedAt": { "type": "string", "format": "date-time" },
"updatedAt": { "type": "string", "format": "date-time" },
"deletedAt": { "type": ["string", "null"], "format": "date-time" },
"manifestUrl": { "type": "string", "format": "uri" },
"sourceKind": { "type": "string", "enum": ["marketplace", "github", "manifest"] },
"verified": { "type": "boolean" },
"repositoryUrl": { "type": ["string", "null"], "format": "uri" },
"releaseTag": { "type": ["string", "null"] },
"publisher": { "type": ["string", "null"], "enum": ["edgeever", null] }
},
"required": ["extensionId", "type", "version", "enabled", "installedAt", "updatedAt", "deletedAt", "manifestUrl", "sourceKind", "verified", "repositoryUrl", "releaseTag", "publisher"]
},
"WorkspaceExtensionUpsertInput": {
"type": "object",
"properties": {
"type": { "type": "string", "enum": ["plugin", "theme"] },
"version": { "type": "string" },
"enabled": { "type": "boolean" },
"installedAt": { "type": "string", "format": "date-time" },
"manifestUrl": { "type": "string", "format": "uri" },
"sourceKind": { "type": "string", "enum": ["marketplace", "github", "manifest"] },
"verified": { "type": "boolean" },
"repositoryUrl": { "type": ["string", "null"], "format": "uri" },
"releaseTag": { "type": ["string", "null"] },
"publisher": { "type": ["string", "null"], "enum": ["edgeever", null] }
},
"required": ["type", "version", "enabled", "installedAt", "manifestUrl", "sourceKind", "verified"]
},
"ApiError": {
"type": "object",
"properties": {
+3 -3
View File
@@ -1,6 +1,6 @@
# 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. On desktop, users can schedule a registered plugin command while EdgeEver is running. Webhooks, an always-on server background runtime, unrestricted TipTap extensions, and a hard JavaScript sandbox are not part of this 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. The install list follows the current workspace on Web and desktop; each browser or desktop app downloads and verifies the package itself. Android and iOS apps do not run plugins. Extensions run only while EdgeEver is open. On desktop, users can schedule a registered plugin command while EdgeEver is running. Webhooks, an always-on server background runtime, unrestricted TipTap extensions, and a hard JavaScript sandbox are not part of this preview.
## Security model
@@ -562,13 +562,13 @@ The first demonstrates note queries, selection replacement, commands, and a cust
## Current limits
- Plugins are installed on one device and are not synchronized.
- The install list follows the current workspace on Web and desktop. Each client re-downloads and verifies packages. Native Android and iOS apps do not run plugins.
- Plugin settings, ordinary plugin storage, and secrets stay on the current device and are not synchronized.
- Plugins run only while the app is open.
- Desktop plugins can persistently schedule their own registered commands, and users can manage those schedules and inspect paginated run history from the plugin page. A schedule is synced through the workspace, bound to one desktop device, and runs only while EdgeEver is open on that device. A missed occurrence can either be skipped or coalesced into one recovery run. This is not an always-on server background runtime.
- There is no webhook receiver, server background runtime, marketplace submission backend, or automated review pipeline.
- Capability declarations are optional descriptive metadata, not API authorization or a sandbox.
- 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.
## Generic AI and public network capabilities (unreleased)
+3 -3
View File
@@ -1,6 +1,6 @@
# EdgeEver 插件开发(P0 预览版)
EdgeEver P0 扩展 API 支持受信任的客户端插件和无代码主题包。用户可以从已验证插件市场、公开 GitHub 仓库或 Manifest 地址安装扩展;扩展安装在当前设备,并且只在 EdgeEver 打开期间运行。桌面端用户可以为已注册的插件命令设置定时计划,并在 EdgeEver 运行期间执行。当前预览版不包含 Webhook、服务端常驻后台运行时、不受约束的 TipTap 扩展和严格的 JavaScript 沙箱。
EdgeEver P0 扩展 API 支持受信任的客户端插件和无代码主题包。用户可以从已验证插件市场、公开 GitHub 仓库或 Manifest 地址安装扩展。安装清单会随当前工作区在 Web 与桌面端之间同步,每个浏览器或桌面应用会自行下载并校验插件包;Android 和 iOS 应用不运行插件。扩展只在 EdgeEver 打开期间运行。桌面端用户可以为已注册的插件命令设置定时计划,并在 EdgeEver 运行期间执行。当前预览版不包含 Webhook、服务端常驻后台运行时、不受约束的 TipTap 扩展和严格的 JavaScript 沙箱。
## 安全模型
@@ -562,13 +562,13 @@ mount(container, { shell, requestClose }) {
## 当前限制
- 插件只安装在当前设备,不参与同步。
- 安装清单会随当前工作区在 Web 与桌面端之间同步;每个客户端会重新下载并校验插件包。Android 和 iOS 原生应用不运行插件。
- 插件设置、普通插件存储和 Secret 仍只保存在当前设备,不会同步。
- 插件只在应用打开期间运行。
- 桌面插件可以持久化定时执行自己的已注册命令,用户则可以在插件页面管理这些计划并分页查看执行记录。计划通过工作区同步、绑定一台桌面设备,并且只在该设备运行 EdgeEver 时执行;错过的计划可以选择跳过,或在恢复后合并补跑一次。这不是服务端常驻后台运行时。
- 暂无 Webhook 接收端、服务端后台运行环境、市场投稿后台和自动审核流水线。
- 能力声明只是可选的描述性元数据,不是 API 授权或安全沙箱。
- 自定义面板可以从桌面端统一插件菜单或插件管理页打开,尚未支持固定到主导航或编辑器侧栏。
- Secret Storage 仅保存在当前设备,不会同步到其他设备。
## 通用 AI 与公开网络能力(尚未发布)
+25
View File
@@ -0,0 +1,25 @@
PRAGMA foreign_keys = ON;
CREATE TABLE workspace_extensions (
workspace_id TEXT NOT NULL,
extension_id TEXT NOT NULL,
extension_type TEXT NOT NULL CHECK (extension_type IN ('plugin', 'theme')),
version TEXT NOT NULL CHECK (length(trim(version)) > 0 AND length(version) <= 80),
enabled INTEGER NOT NULL CHECK (enabled IN (0, 1)),
source_kind TEXT NOT NULL CHECK (source_kind IN ('marketplace', 'github', 'manifest')),
verified INTEGER NOT NULL CHECK (verified IN (0, 1)),
manifest_url TEXT NOT NULL CHECK (length(trim(manifest_url)) > 0 AND length(manifest_url) <= 2000),
repository_url TEXT CHECK (repository_url IS NULL OR (length(trim(repository_url)) > 0 AND length(repository_url) <= 500)),
release_tag TEXT CHECK (release_tag IS NULL OR (length(trim(release_tag)) > 0 AND length(release_tag) <= 80)),
publisher TEXT CHECK (publisher IS NULL OR publisher = 'edgeever'),
installed_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
deleted_at TEXT,
PRIMARY KEY (workspace_id, extension_id),
FOREIGN KEY (workspace_id) REFERENCES workspaces(id)
ON UPDATE CASCADE
ON DELETE CASCADE
);
CREATE INDEX idx_workspace_extensions_workspace_updated
ON workspace_extensions(workspace_id, deleted_at, updated_at DESC);
+24
View File
@@ -34,6 +34,8 @@ import type {
MemoTemplate,
ScheduledTask,
ScheduledTaskRun,
WorkspaceExtension,
WorkspaceExtensionUpsertInput,
Notebook,
Resource,
ResourceListItem,
@@ -199,6 +201,14 @@ export type ListScheduledTasksResponse = {
tasks: ScheduledTask[];
};
export type ListWorkspaceExtensionsResponse = {
extensions: WorkspaceExtension[];
};
export type WorkspaceExtensionResponse = {
extension: WorkspaceExtension;
};
export type ScheduledTaskResponse = {
task: ScheduledTask;
};
@@ -1091,6 +1101,20 @@ export const createEdgeEverClient = (options: EdgeEverClientOptions = {}) => {
return request<ListScheduledTasksResponse>(`/api/v1/scheduled-tasks${suffix}`);
},
listWorkspaceExtensions: () => request<ListWorkspaceExtensionsResponse>("/api/v1/workspace-extensions"),
upsertWorkspaceExtension: (extensionId: string, payload: WorkspaceExtensionUpsertInput) =>
request<WorkspaceExtensionResponse>(
`/api/v1/workspace-extensions/${encodeURIComponent(extensionId)}`,
{ method: "PUT", body: JSON.stringify(payload) },
),
deleteWorkspaceExtension: (extensionId: string) =>
request<WorkspaceExtensionResponse>(
`/api/v1/workspace-extensions/${encodeURIComponent(extensionId)}`,
{ method: "DELETE" },
),
listScheduledTaskRunHistory: (offset = 0, limit = 50) => {
const search = new URLSearchParams({ offset: String(offset), limit: String(limit) });
return request<ScheduledTaskRunHistoryResponse>(`/api/v1/scheduled-task-runs?${search.toString()}`);
+2 -1
View File
@@ -718,7 +718,8 @@ export const enUS = {
sourcePlaceholder: "https://github.com/owner/edgeever-plugin",
install: "Install",
installing: "Installing…",
empty: "No plugins or themes are installed on this device.",
empty: "This workspace has no plugins or themes installed yet.",
syncDescription: "The install list follows this workspace on Web and desktop. Each browser or desktop app downloads the packages itself. Android and iOS apps do not run plugins. Settings and secrets stay on this device.",
permissions: {
publicNetwork: "Read content from any public website",
},
+2 -1
View File
@@ -718,7 +718,8 @@ export const zhCN = {
sourcePlaceholder: "https://github.com/owner/edgeever-plugin",
install: "安装",
installing: "安装中…",
empty: "当前设备尚未安装插件或主题。",
empty: "这个工作区还没有安装插件或主题。",
syncDescription: "安装清单会随这个工作区在 Web 和桌面端之间同步,每个浏览器或桌面应用会自行下载并校验插件包。Android 和 iOS 应用不运行插件。设置和密钥仍只保存在当前设备。",
permissions: {
publicNetwork: "读取任意公开网站内容",
},
+27
View File
@@ -123,6 +123,32 @@ export const ScheduledTaskFinishSchema = z.object({
errorMessage: z.string().trim().max(2_000).nullable().optional(),
});
const httpUrl = z.string().trim().max(2000).refine((value) => {
try {
const parsed = new URL(value);
return parsed.protocol === "http:" || parsed.protocol === "https:";
} catch {
return false;
}
}, { message: "URL must use http or https." });
export const WorkspaceExtensionUpsertSchema = z.object({
type: z.enum(["plugin", "theme"]),
version: z.string().trim().regex(/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/).max(80),
enabled: z.boolean(),
installedAt: z.string().datetime(),
manifestUrl: httpUrl,
sourceKind: z.enum(["marketplace", "github", "manifest"]),
verified: z.boolean(),
repositoryUrl: httpUrl.max(500).nullable().optional(),
releaseTag: z.string().trim().min(1).max(80).nullable().optional(),
publisher: z.literal("edgeever").nullable().optional(),
}).refine((input) => {
if (input.sourceKind === "github") return Boolean(input.repositoryUrl);
if (input.sourceKind === "marketplace") return Boolean(input.repositoryUrl || input.manifestUrl);
return true;
}, { message: "GitHub and marketplace extensions require a source URL." });
export const MoveMemosSchema = z.object({
memoIds: z.array(z.string().trim().min(1)).min(1).max(100),
notebookId: z.string().trim().min(1),
@@ -367,6 +393,7 @@ export type ScheduledTaskUpdateInput = z.infer<typeof ScheduledTaskUpdateSchema>
export type PluginScheduleUpsertInput = z.input<typeof PluginScheduleUpsertSchema>;
export type ScheduledTaskClaimInput = z.infer<typeof ScheduledTaskClaimSchema>;
export type ScheduledTaskFinishInput = z.infer<typeof ScheduledTaskFinishSchema>;
export type WorkspaceExtensionUpsertInput = z.infer<typeof WorkspaceExtensionUpsertSchema>;
export type MoveMemosInput = z.infer<typeof MoveMemosSchema>;
export type DeleteMemosInput = z.infer<typeof DeleteMemosSchema>;
export type MergeMemosInput = z.infer<typeof MergeMemosSchema>;
+18
View File
@@ -55,6 +55,24 @@ export type MemoTemplate = {
updatedAt: string;
};
export type WorkspaceExtensionSourceKind = "marketplace" | "github" | "manifest";
export type WorkspaceExtension = {
extensionId: string;
type: "plugin" | "theme";
version: string;
enabled: boolean;
installedAt: string;
updatedAt: string;
deletedAt: string | null;
manifestUrl: string;
sourceKind: WorkspaceExtensionSourceKind;
verified: boolean;
repositoryUrl: string | null;
releaseTag: string | null;
publisher: "edgeever" | null;
};
export type ScheduledTaskMissedRunPolicy = "run-once" | "skip";
export type ScheduledPluginCommandPayload = {