diff --git a/README.md b/README.md index 98260c3f..10778508 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/README.zh-CN.md b/README.zh-CN.md index 80f0fcb1..350cae32 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -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)。 ## 技术栈 diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 5c86d4b0..118d2f8c 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -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), diff --git a/apps/api/src/workspace-extension-routes.test.mjs b/apps/api/src/workspace-extension-routes.test.mjs new file mode 100644 index 00000000..abbf73e6 --- /dev/null +++ b/apps/api/src/workspace-extension-routes.test.mjs @@ -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); + }); +}); diff --git a/apps/api/src/workspace-extension-routes.ts b/apps/api/src/workspace-extension-routes.ts new file mode 100644 index 00000000..a6a9dde8 --- /dev/null +++ b/apps/api/src/workspace-extension-routes.ts @@ -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(); + +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, + 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(); + 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 }) }); + }); +}; diff --git a/apps/web/src/components/WorkspaceApp.tsx b/apps/web/src/components/WorkspaceApp.tsx index 2d10d3f8..c7a97681 100644 --- a/apps/web/src/components/WorkspaceApp.tsx +++ b/apps/web/src/components/WorkspaceApp.tsx @@ -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(null); const [notebookDeleteConfirmation, setNotebookDeleteConfirmation] = useState(null); const [appNoticeDialog, setAppNoticeDialog] = useState(null); + const [pendingCatalogTrustPluginIds, setPendingCatalogTrustPluginIds] = useState([]); const [isExportingSelectedMemos, setIsExportingSelectedMemos] = useState(false); const [selectedMarkdownExportProgress, setSelectedMarkdownExportProgress] = useState({ 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 ? ( + setPendingCatalogTrustPluginIds([])} + onConfirm={() => { + const pluginIds = pendingCatalogTrustPluginIds; + acknowledgePluginTrustWarning(); + setPendingCatalogTrustPluginIds([]); + void Promise.all(pluginIds.map((pluginId) => pluginHost.setEnabled(pluginId, true).catch(() => undefined))); + }} + /> + ) : null} {demoResetConfirmationOpen && ( -
- - - {selectedPluginId ? t("plugins.details.title") : t("plugins.title")} - - Beta - - +
+
+ + + {selectedPluginId ? t("plugins.details.title") : t("plugins.title")} + + Beta + + + {selectedPluginId ? null : ( +

{t("plugins.syncDescription")}

+ )} +
{snapshot.extensions.length > 0 || (marketplaceQuery.data?.entries.length ?? 0) > 0 ? (