From dc7626843ad5bcf4906ee9c272735bcb904c79d2 Mon Sep 17 00:00:00 2001 From: tianma-if Date: Sun, 16 Aug 2026 19:34:16 +0800 Subject: [PATCH] feat: add plugin updates and details --- apps/api/src/index.ts | 2 + .../src/plugin-distribution-routes.test.mjs | 31 ++ apps/api/src/plugin-distribution-routes.ts | 78 ++++ apps/web/public/extensions/registry.json | 40 +- apps/web/src/app/App.tsx | 1 + .../src/components/PluginMarketplacePane.tsx | 19 +- .../components/plugins/PluginUpdateDialog.tsx | 83 ++++ .../components/settings/PluginManagerCard.tsx | 416 +++++++++++++++--- apps/web/src/hooks/useWorkspaceRoute.test.mjs | 1 + apps/web/src/hooks/useWorkspaceRoute.ts | 2 +- apps/web/src/lib/api.ts | 26 ++ .../github-plugin-distribution.test.mjs | 42 +- .../lib/plugins/github-plugin-distribution.ts | 112 +++-- apps/web/src/lib/plugins/plugin-host.test.mjs | 26 ++ apps/web/src/lib/plugins/plugin-host.ts | 20 +- .../lib/plugins/plugin-marketplace.test.mjs | 2 + .../src/lib/plugins/plugin-updates.test.mjs | 75 ++++ apps/web/src/lib/plugins/plugin-updates.ts | 103 +++++ apps/web/src/lib/version-check.test.mjs | 3 + apps/web/src/lib/version-check.ts | 26 +- docs/plugin-development.md | 2 + docs/plugin-development.zh-CN.md | 2 + packages/shared/src/i18n/en-US.ts | 28 +- packages/shared/src/i18n/zh-CN.ts | 28 +- 24 files changed, 998 insertions(+), 170 deletions(-) create mode 100644 apps/api/src/plugin-distribution-routes.test.mjs create mode 100644 apps/api/src/plugin-distribution-routes.ts create mode 100644 apps/web/src/components/plugins/PluginUpdateDialog.tsx create mode 100644 apps/web/src/lib/plugins/plugin-updates.test.mjs create mode 100644 apps/web/src/lib/plugins/plugin-updates.ts diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 2125f254..d14ad9d2 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -126,6 +126,7 @@ import { } from "./ai-prompt-service"; import { restoreMissingDefaultAiPrompts } from "./ai-prompt-seed"; import { registerResourceRoutes } from "./resource-routes"; +import { registerPluginDistributionRoutes } from "./plugin-distribution-routes"; import { registerSyncRoutes } from "./sync-routes"; import { registerMemoRoutes } from "./memo-routes"; import { registerBackupRoutes } from "./backup-routes"; @@ -360,6 +361,7 @@ registerSyncRoutes(app, { mapMemoDetail: (...args) => mapMemoDetail(...args), }); registerTagRoutes(app); +registerPluginDistributionRoutes(app); registerMemoShareRoutes(app); registerTemplateRoutes(app, { createMemoRecord: (...args) => createMemoRecord(...args), diff --git a/apps/api/src/plugin-distribution-routes.test.mjs b/apps/api/src/plugin-distribution-routes.test.mjs new file mode 100644 index 00000000..51253787 --- /dev/null +++ b/apps/api/src/plugin-distribution-routes.test.mjs @@ -0,0 +1,31 @@ +import { describe, expect, test } from "bun:test"; +import { downloadGithubReleaseAsset } from "./plugin-distribution-routes.ts"; + +describe("GitHub plugin release asset proxy", () => { + test("downloads a supported release asset on the server", async () => { + const calls = []; + const buffer = await downloadGithubReleaseAsset({ + owner: "example", + repository: "edgeever-plugin", + assetId: "42", + assetName: "main.js", + request: async (input) => { + calls.push(String(input)); + return new Response("export default {};", { headers: { "content-length": "18" } }); + }, + }); + + expect(new TextDecoder().decode(buffer)).toBe("export default {};"); + expect(calls).toEqual(["https://api.github.com/repos/example/edgeever-plugin/releases/assets/42"]); + }); + + test("rejects an oversized release asset", async () => { + await expect(downloadGithubReleaseAsset({ + owner: "example", + repository: "edgeever-plugin", + assetId: "42", + assetName: "manifest.json", + request: async () => new Response("", { headers: { "content-length": String(300 * 1024) } }), + })).rejects.toThrow("exceeds the allowed package size"); + }); +}); diff --git a/apps/api/src/plugin-distribution-routes.ts b/apps/api/src/plugin-distribution-routes.ts new file mode 100644 index 00000000..9ec8bb7b --- /dev/null +++ b/apps/api/src/plugin-distribution-routes.ts @@ -0,0 +1,78 @@ +import type { Hono } from "hono"; +import type { AppEnv } from "./api-context"; +import { apiError, badRequest } from "./http-errors"; + +const GITHUB_API_VERSION = "2022-11-28"; +const OWNER_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,38})$/i; +const REPOSITORY_PATTERN = /^[a-z0-9._-]+$/i; +const ASSET_ID_PATTERN = /^\d+$/; +const ASSET_LIMITS = { + "manifest.json": 256 * 1024, + "main.js": 5 * 1024 * 1024, + "styles.css": 1024 * 1024, +} as const; + +type SupportedPluginAssetName = keyof typeof ASSET_LIMITS; + +export const downloadGithubReleaseAsset = async ({ + owner, + repository, + assetId, + assetName, + request = fetch, +}: { + owner: string; + repository: string; + assetId: string; + assetName: SupportedPluginAssetName; + request?: typeof fetch; +}) => { + const maximumBytes = ASSET_LIMITS[assetName]; + const response = await request(`https://api.github.com/repos/${owner}/${repository}/releases/assets/${assetId}`, { + redirect: "follow", + headers: { + Accept: "application/octet-stream", + "User-Agent": "EdgeEver", + "X-GitHub-Api-Version": GITHUB_API_VERSION, + }, + }); + if (!response.ok) throw new Error(`GitHub asset ${assetName} failed with HTTP ${response.status}.`); + const declaredSize = Number(response.headers.get("content-length") ?? 0); + if (declaredSize > maximumBytes) throw new Error(`${assetName} exceeds the allowed package size.`); + const buffer = await response.arrayBuffer(); + if (buffer.byteLength > maximumBytes) throw new Error(`${assetName} exceeds the allowed package size.`); + return buffer; +}; + +export const registerPluginDistributionRoutes = (app: Hono) => { + app.get("/api/v1/plugins/github/:owner/:repository/assets/:assetId/:assetName", async (context) => { + const owner = context.req.param("owner"); + const repository = context.req.param("repository"); + const assetId = context.req.param("assetId"); + const assetName = context.req.param("assetName"); + if (!OWNER_PATTERN.test(owner) || !REPOSITORY_PATTERN.test(repository) || !ASSET_ID_PATTERN.test(assetId)) { + return badRequest(context, "Invalid GitHub release asset coordinates."); + } + if (!(assetName in ASSET_LIMITS)) return badRequest(context, "Unsupported plugin release asset."); + + try { + const buffer = await downloadGithubReleaseAsset({ + owner, + repository, + assetId, + assetName: assetName as SupportedPluginAssetName, + }); + return context.body(buffer, 200, { + "Cache-Control": "private, max-age=300", + "Content-Type": "application/octet-stream", + }); + } catch (error) { + return apiError( + context, + "github_asset_download_failed", + error instanceof Error ? error.message : "GitHub release asset download failed.", + 502, + ); + } + }); +}; diff --git a/apps/web/public/extensions/registry.json b/apps/web/public/extensions/registry.json index 5b5b37c3..707eca72 100644 --- a/apps/web/public/extensions/registry.json +++ b/apps/web/public/extensions/registry.json @@ -1,43 +1,5 @@ { "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" - } - } - } - ] + "entries": [] } diff --git a/apps/web/src/app/App.tsx b/apps/web/src/app/App.tsx index 3390f364..8a2dbe53 100644 --- a/apps/web/src/app/App.tsx +++ b/apps/web/src/app/App.tsx @@ -228,6 +228,7 @@ export const App = () => { } /> } /> } /> + } /> } /> } /> } /> diff --git a/apps/web/src/components/PluginMarketplacePane.tsx b/apps/web/src/components/PluginMarketplacePane.tsx index 317759ef..2b11ba6f 100644 --- a/apps/web/src/components/PluginMarketplacePane.tsx +++ b/apps/web/src/components/PluginMarketplacePane.tsx @@ -1,5 +1,6 @@ import { ChevronLeft, Store } from "lucide-react"; import { useTranslation } from "react-i18next"; +import { useNavigate, useParams } from "react-router"; import * as m from "motion/react-m"; import { Button } from "@/components/ui/button"; import { ThemeToggle } from "@/components/ThemeToggle"; @@ -16,6 +17,15 @@ export const PluginMarketplacePane = ({ onClose: () => void; }) => { const { t } = useTranslation(); + const navigate = useNavigate(); + const { pluginId = null } = useParams<{ pluginId: string }>(); + const close = () => { + if (pluginId) { + navigate("/plugins"); + return; + } + onClose(); + }; return (
@@ -25,7 +35,7 @@ export const PluginMarketplacePane = ({ size="icon" variant="ghost" aria-label={t("common.back")} - onClick={onClose} + onClick={close} className="h-9 w-9 rounded-lg hover:bg-slate-100" > @@ -40,7 +50,12 @@ export const PluginMarketplacePane = ({
- + navigate(`/plugins/${encodeURIComponent(id)}`)} + onClosePlugin={() => navigate("/plugins")} + />
diff --git a/apps/web/src/components/plugins/PluginUpdateDialog.tsx b/apps/web/src/components/plugins/PluginUpdateDialog.tsx new file mode 100644 index 00000000..9e9cb230 --- /dev/null +++ b/apps/web/src/components/plugins/PluginUpdateDialog.tsx @@ -0,0 +1,83 @@ +import { AlertTriangle, ArrowRight, ShieldCheck } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import type { PluginUpdateInfo } from "@/lib/plugins/plugin-updates"; + +const permissionLabel = (permission: string) => permission.replace(":", " · "); + +export const PluginUpdateDialog = ({ + isUpdating, + update, + onCancel, + onConfirm, +}: { + isUpdating: boolean; + update: PluginUpdateInfo; + onCancel: () => void; + onConfirm: () => void; +}) => { + const { t } = useTranslation(); + const hasAddedAccess = update.addedPermissions.length > 0 || update.addedNetworkHosts.length > 0; + + return ( + { if (!open && !isUpdating) onCancel(); }}> + + + + {hasAddedAccess ? : } + +
+ + {t("plugins.updates.confirmTitle", { name: update.latestManifest.name })} + + + v{update.currentVersion} + + v{update.latestVersion} + +
+
+ +
+

+ {hasAddedAccess ? t("plugins.updates.addedAccessDescription") : t("plugins.updates.confirmDescription")} +

+ {update.addedPermissions.length > 0 ? ( +
+
{t("plugins.updates.addedPermissions")}
+
+ {update.addedPermissions.map((permission) => ( + + {permissionLabel(permission)} + + ))} +
+
+ ) : null} + {update.addedNetworkHosts.length > 0 ? ( +
+
{t("plugins.updates.addedNetworkHosts")}
+
+ {update.addedNetworkHosts.map((hostname) => ( + {hostname} + ))} +
+
+ ) : null} +
+ + + + + +
+
+ ); +}; diff --git a/apps/web/src/components/settings/PluginManagerCard.tsx b/apps/web/src/components/settings/PluginManagerCard.tsx index 302a5a12..fc8fb7ae 100644 --- a/apps/web/src/components/settings/PluginManagerCard.tsx +++ b/apps/web/src/components/settings/PluginManagerCard.tsx @@ -1,20 +1,158 @@ import { useEffect, useState, useSyncExternalStore } from "react"; -import { BadgeCheck, BookOpen, Boxes, Download, ExternalLink, PanelRightOpen, Play, Puzzle, RefreshCw, Store, Trash2 } from "lucide-react"; +import { BadgeCheck, BookOpen, Download, ExternalLink, PanelRightOpen, Play, Puzzle, RefreshCw, Trash2 } from "lucide-react"; import { useTranslation } from "react-i18next"; -import { useQuery } from "@tanstack/react-query"; +import { useQuery, useQueryClient } 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 type { EdgeEverPluginHost, InstalledExtension, RegisteredPluginCommand, RegisteredPluginPanel } from "@/lib/plugins/plugin-host"; import { PluginPanelDialog } from "@/components/plugins/PluginPanelDialog"; import { loadPluginMarketplace } from "@/lib/plugins/plugin-marketplace"; import { GitHubMark } from "@/components/GitHubRepositoryLink"; +import { checkPluginUpdates, type PluginUpdateInfo } from "@/lib/plugins/plugin-updates"; +import { PluginUpdateDialog } from "@/components/plugins/PluginUpdateDialog"; const permissionLabel = (permission: string) => permission.replace(":", " · "); -export const PluginManagerCard = ({ host }: { host: EdgeEverPluginHost }) => { +const PluginDetailView = ({ + commands, + extension, + panels, + pendingId, + update, + onOpenPanel, + onRunCommand, + onToggle, + onUninstall, + onUpdate, +}: { + commands: RegisteredPluginCommand[]; + extension: InstalledExtension; + panels: RegisteredPluginPanel[]; + pendingId: string | null; + update?: PluginUpdateInfo; + onOpenPanel: (panel: RegisteredPluginPanel) => void; + onRunCommand: (command: RegisteredPluginCommand) => void; + onToggle: (enabled: boolean) => void; + onUninstall: () => void; + onUpdate: () => void; +}) => { const { t, i18n } = useTranslation(); + const { manifest } = extension; + const id = manifest.id; + const sourceKey = extension.source.verified ? "verified" : extension.source.kind; + + return ( +
+
+
+
+

{manifest.name}

+ {manifest.type} + {update ? ( + + {t("plugins.updates.available", { version: update.latestVersion })} + + ) : null} +
+ {manifest.description ?

{manifest.description}

: null} +
+ +
+ +
+ {[ + [t("plugins.details.type"), manifest.type], + [t("plugins.details.version"), `v${manifest.version}`], + [t("plugins.details.source"), t(`plugins.sources.${sourceKey}`)], + [t("plugins.details.installedAt"), new Date(extension.installedAt).toLocaleString(i18n.language)], + ].map(([label, value]) => ( +
+
{label}
+
{value}
+
+ ))} +
+ + {extension.source.repositoryUrl ? ( + + + {extension.source.repositoryUrl.replace("https://github.com/", "")} + + + ) : null} + + {manifest.type === "plugin" && manifest.permissions.length > 0 ? ( +
+

{t("plugins.details.permissions")}

+
+ {manifest.permissions.map((permission) => ( + {permissionLabel(permission)} + ))} +
+
+ ) : null} + + {manifest.type === "plugin" && manifest.networkHosts?.length ? ( +
+

{t("plugins.details.networkHosts")}

+
+ {manifest.networkHosts.map((hostname) => ( + {hostname} + ))} +
+
+ ) : null} + + {extension.error ?
{extension.error}
: null} + +
+ {update ? ( + + ) : null} + {commands.map((command) => ( + + ))} + {panels.map((panel) => ( + + ))} + +
+
+ ); +}; + +export const PluginManagerCard = ({ + host, + onClosePlugin, + onOpenPlugin, + selectedPluginId, +}: { + host: EdgeEverPluginHost; + onClosePlugin?: () => void; + onOpenPlugin?: (pluginId: string) => void; + selectedPluginId?: string | null; +}) => { + const { t, i18n } = useTranslation(); + const queryClient = useQueryClient(); const developerDocsUrl = i18n.resolvedLanguage?.startsWith("zh") ? "https://github.com/tianma-if/edgeever/blob/main/docs/plugin-development.zh-CN.md" : "https://github.com/tianma-if/edgeever/blob/main/docs/plugin-development.md"; @@ -22,9 +160,28 @@ export const PluginManagerCard = ({ host }: { host: EdgeEverPluginHost }) => { const [manifestUrl, setManifestUrl] = useState(""); const [pendingId, setPendingId] = useState(null); const [installing, setInstalling] = useState(false); + const [manuallyChecking, setManuallyChecking] = useState(false); + const [lastManualCheckCount, setLastManualCheckCount] = useState(null); const [error, setError] = useState(null); const [activePanel, setActivePanel] = useState(null); + const [pendingUpdate, setPendingUpdate] = useState(null); const marketplaceQuery = useQuery({ queryKey: ["plugin-marketplace", "v1"], queryFn: () => loadPluginMarketplace(), staleTime: 5 * 60_000 }); + const extensionVersionKey = snapshot.extensions + .map((extension) => `${extension.manifest.id}:${extension.manifest.version}:${extension.source.kind}`) + .join("|"); + const marketplaceVersionKey = marketplaceQuery.data?.updatedAt ?? "unavailable"; + const updateQueryKey = ["plugin-updates", extensionVersionKey, marketplaceVersionKey] as const; + const updateQuery = useQuery({ + queryKey: updateQueryKey, + queryFn: () => checkPluginUpdates(snapshot.extensions, marketplaceQuery.data?.entries ?? []), + enabled: snapshot.extensions.length > 0 && !marketplaceQuery.isLoading, + staleTime: 5 * 60_000, + refetchInterval: 30 * 60_000, + refetchOnWindowFocus: true, + }); + const selectedExtension = selectedPluginId + ? snapshot.extensions.find((extension) => extension.manifest.id === selectedPluginId) + : undefined; const activePanelPluginId = activePanel?.pluginId ?? null; const activePanelId = activePanel?.id ?? null; const activePanelRegistered = Boolean(activePanelPluginId && activePanelId && snapshot.panels.some( @@ -42,6 +199,7 @@ export const PluginManagerCard = ({ host }: { host: EdgeEverPluginHost }) => { try { await host.installFromSource(manifestUrl.trim()); setManifestUrl(""); + setLastManualCheckCount(null); } catch (installError) { setError(installError instanceof Error ? installError.message : String(installError)); } finally { @@ -61,59 +219,128 @@ export const PluginManagerCard = ({ host }: { host: EdgeEverPluginHost }) => { } }; + const checkForUpdates = async () => { + setManuallyChecking(true); + setError(null); + try { + const refreshedMarketplace = await marketplaceQuery.refetch(); + const result = await checkPluginUpdates(snapshot.extensions, refreshedMarketplace.data?.entries ?? []); + queryClient.setQueryData( + ["plugin-updates", extensionVersionKey, refreshedMarketplace.data?.updatedAt ?? "unavailable"], + result, + ); + setLastManualCheckCount(result.updates.length); + const firstCheckError = Object.values(result.errors)[0]; + if (firstCheckError) setError(t("plugins.updates.checkFailed", { message: firstCheckError })); + } catch (checkError) { + setError(checkError instanceof Error ? checkError.message : String(checkError)); + } finally { + setManuallyChecking(false); + } + }; + + const applyUpdate = async (update: PluginUpdateInfo) => { + const extension = snapshot.extensions.find((candidate) => candidate.manifest.id === update.pluginId); + if (!extension) throw new Error("Extension is no longer installed."); + if (extension.source.kind === "marketplace") { + if (!update.marketplaceEntry) throw new Error("The verified marketplace entry is no longer available."); + await host.installMarketplaceEntry(update.marketplaceEntry, update.latestManifest); + } else if (extension.source.kind === "github") { + if (!extension.source.repositoryUrl) throw new Error("Installed GitHub extension is missing its repository URL."); + await host.installFromGithubRepository(extension.source.repositoryUrl, undefined, update.latestManifest); + } else { + await host.installFromManifestUrl(extension.manifestUrl, undefined, update.latestManifest); + } + setPendingUpdate(null); + setLastManualCheckCount(null); + await updateQuery.refetch(); + }; + return (
- {t("plugins.title")} + {selectedPluginId ? t("plugins.details.title") : t("plugins.title")} - +
+ {snapshot.extensions.length > 0 ? ( + + ) : null} + +
-
-
-
-
- - {t("plugins.marketplace.title")} -
-

{t("plugins.marketplace.description")}

-
- -
+ {error ?
{error}
: null} - {marketplaceQuery.isLoading ? ( -
{t("plugins.marketplace.loading")}
- ) : marketplaceQuery.error ? ( -
- {marketplaceQuery.error instanceof Error ? marketplaceQuery.error.message : String(marketplaceQuery.error)} -
+ {selectedPluginId ? ( + selectedExtension ? ( + update.pluginId === selectedExtension.manifest.id)} + commands={snapshot.commands.filter((command) => command.pluginId === selectedExtension.manifest.id)} + panels={snapshot.panels.filter((panel) => panel.pluginId === selectedExtension.manifest.id)} + pendingId={pendingId} + onToggle={(enabled) => void run(selectedExtension.manifest.id, () => host.setEnabled(selectedExtension.manifest.id, enabled))} + onUpdate={() => { + const update = updateQuery.data?.updates.find((candidate) => candidate.pluginId === selectedExtension.manifest.id); + if (update) setPendingUpdate(update); + }} + onRunCommand={(command) => void run( + `${selectedExtension.manifest.id}:${command.id}`, + () => host.runCommand(selectedExtension.manifest.id, command.id), + )} + onOpenPanel={setActivePanel} + onUninstall={() => void run(`remove:${selectedExtension.manifest.id}`, async () => { + await host.uninstall(selectedExtension.manifest.id); + onClosePlugin?.(); + })} + /> ) : ( -
- {(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 ( -
+
+ {t("plugins.details.notFound")} +
+ ) + ) : ( + <> + {(marketplaceQuery.data?.entries.length ?? 0) > 0 ? ( +
+ {(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 marketplaceUpdate = updateQuery.data?.updates.find((update) => update.pluginId === entry.id && update.marketplaceEntry); + const actionId = `marketplace:${entry.id}`; + return ( +
@@ -137,24 +364,31 @@ export const PluginManagerCard = ({ host }: { host: EdgeEverPluginHost }) => { 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); })} + disabled={Boolean(currentVerified) || pendingId === actionId || Boolean(installed?.source.kind === "marketplace" && !marketplaceUpdate)} + onClick={() => { + if (marketplaceUpdate) { + setPendingUpdate(marketplaceUpdate); + return; + } + void run(actionId, async () => { await host.installMarketplaceEntry(entry); }); + }} > {pendingId === actionId ? t("plugins.installing") - : currentVerified + : marketplaceUpdate + ? t("plugins.updates.update") + : currentVerified ? t("plugins.marketplace.installed") : installed ? t("plugins.marketplace.installVerified") : t("plugins.install")} -
- ); - })} -
- )} -
+ + ); + })} + + ) : null}
{
- {error ?
{error}
: null} - {snapshot.extensions.length === 0 ? (
{t("plugins.empty")}
) : ( -
+
{snapshot.extensions.map((extension) => { const id = extension.manifest.id; + const availableUpdate = updateQuery.data?.updates.find((update) => update.pluginId === id); const commands = snapshot.commands.filter((command) => command.pluginId === id); const panels = snapshot.panels.filter((panel) => panel.pluginId === id); return ( -
+
{ + if ((event.target as HTMLElement).closest("button, a")) return; + onOpenPlugin?.(id); + }} + onKeyDown={(event) => { + if (event.target === event.currentTarget && (event.key === "Enter" || event.key === " ")) { + event.preventDefault(); + onOpenPlugin?.(id); + } + }} + >
-
-
+
+
{extension.manifest.name} {extension.manifest.type} v{extension.manifest.version} + {availableUpdate ? ( + + {t("plugins.updates.available", { version: availableUpdate.latestVersion })} + + ) : null} {extension.source.verified ? : extension.source.kind === "github" ? : null} {t(`plugins.sources.${extension.source.verified ? "verified" : extension.source.kind}`)}
- {extension.manifest.description ?

{extension.manifest.description}

: null} + {extension.manifest.description ?

{extension.manifest.description}

: null} {extension.source.repositoryUrl ? ( - - - {extension.source.repositoryUrl.replace("https://github.com/", "")} + + + {extension.source.repositoryUrl.replace("https://github.com/", "")} ) : null}
@@ -214,17 +468,33 @@ export const PluginManagerCard = ({ host }: { host: EdgeEverPluginHost }) => { {extension.manifest.type === "plugin" && extension.manifest.permissions.length > 0 ? (
- {extension.manifest.permissions.map((permission) => ( + {extension.manifest.permissions.slice(0, 3).map((permission) => ( {permissionLabel(permission)} ))} + {extension.manifest.permissions.length > 3 ? ( + + +{extension.manifest.permissions.length - 3} + + ) : null}
) : null} {extension.error ?
{extension.error}
: null} -
+
+ {availableUpdate ? ( + + ) : null} {commands.map((command) => (
)} + + )} -
- - {t("plugins.localOnly")} -
setActivePanel(null)} /> + {pendingUpdate ? ( + setPendingUpdate(null)} + onConfirm={() => void run(`update:${pendingUpdate.pluginId}`, () => applyUpdate(pendingUpdate))} + /> + ) : null} ); diff --git a/apps/web/src/hooks/useWorkspaceRoute.test.mjs b/apps/web/src/hooks/useWorkspaceRoute.test.mjs index db07cc6c..f1c06429 100644 --- a/apps/web/src/hooks/useWorkspaceRoute.test.mjs +++ b/apps/web/src/hooks/useWorkspaceRoute.test.mjs @@ -13,6 +13,7 @@ describe("workspace route resolution", () => { expect(resolveWorkspaceRoute("/", "?view=trash").isTrash).toBe(true); expect(resolveWorkspaceRoute("/settings", "").isSettings).toBe(true); expect(resolveWorkspaceRoute("/plugins", "").isPlugins).toBe(true); + expect(resolveWorkspaceRoute("/plugins/org.edgeever.example", "").isPlugins).toBe(true); expect(resolveWorkspaceRoute("/templates", "").isTemplates).toBe(true); expect(resolveWorkspaceRoute("/ai-prompts", "").isAiPrompts).toBe(true); }); diff --git a/apps/web/src/hooks/useWorkspaceRoute.ts b/apps/web/src/hooks/useWorkspaceRoute.ts index 69a27df6..84504ecb 100644 --- a/apps/web/src/hooks/useWorkspaceRoute.ts +++ b/apps/web/src/hooks/useWorkspaceRoute.ts @@ -23,7 +23,7 @@ export const resolveWorkspaceRoute = (pathname: string, search: string): Workspa pathname, search, isSettings: pathname === WORKSPACE_SETTINGS_PATH, - isPlugins: pathname === WORKSPACE_PLUGINS_PATH, + isPlugins: pathname === WORKSPACE_PLUGINS_PATH || pathname.startsWith(`${WORKSPACE_PLUGINS_PATH}/`), isTemplates: pathname === WORKSPACE_TEMPLATES_PATH, isAiPrompts: pathname === WORKSPACE_AI_PROMPTS_PATH, isTrash: pathname === "/" && search === WORKSPACE_TRASH_SEARCH, diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index b01c03f9..9094edaa 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -431,6 +431,23 @@ const request = async (path: string, init?: RequestInit): Promise => { return body; }; +const requestArrayBuffer = async (path: string) => { + const isDesktop = Boolean(typeof window !== "undefined" && window.edgeeverDesktop?.isAvailable); + const sessionToken = isDesktop ? getDesktopSessionToken() : undefined; + const headers = new Headers(); + if (sessionToken) headers.set("Authorization", `Bearer ${sessionToken}`); + const response = await fetch(`${getConfiguredDesktopApiBaseUrl()}${path}`, { + credentials: "include", + headers, + }); + if (!response.ok) { + const body = await response.json().catch(() => null) as { error?: { message?: string } } | null; + if (response.status === 401) void notifyUnauthorized(isDesktop, sessionToken); + throw new ApiRequestError(body?.error?.message || response.statusText || "Binary download failed", response.status); + } + return response.arrayBuffer(); +}; + export const api = { getSession: () => request("/api/v1/auth/session"), @@ -913,6 +930,15 @@ export const api = { return response.blob(); }, + downloadGithubPluginAsset: ( + owner: string, + repository: string, + assetId: number, + assetName: "manifest.json" | "main.js" | "styles.css", + ) => requestArrayBuffer( + `/api/v1/plugins/github/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/assets/${assetId}/${encodeURIComponent(assetName)}`, + ), + uploadMemoResource: (memoId: string, file: File) => { const form = new FormData(); form.append("file", file); diff --git a/apps/web/src/lib/plugins/github-plugin-distribution.test.mjs b/apps/web/src/lib/plugins/github-plugin-distribution.test.mjs index d34fd1d3..f416d0d2 100644 --- a/apps/web/src/lib/plugins/github-plugin-distribution.test.mjs +++ b/apps/web/src/lib/plugins/github-plugin-distribution.test.mjs @@ -27,10 +27,7 @@ describe("GitHub plugin distribution", () => { 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("/contents/manifest.json")) 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({ @@ -42,25 +39,27 @@ describe("GitHub plugin distribution", () => { ], }); } - 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 downloadAsset = async (_coordinates, asset) => new TextEncoder().encode( + asset.id === 1 ? JSON.stringify(manifest) : "export default { activate() {} };", + ).buffer; - const downloaded = await downloadGithubExtension("https://github.com/example/edgeever-plugin", request); + const downloaded = await downloadGithubExtension("https://github.com/example/edgeever-plugin", request, downloadAsset); 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/contents/manifest.json"); expect(calls).toContain("https://api.github.com/repos/example/edgeever-plugin/releases/tags/1.2.3"); + expect(calls).not.toContain("https://api.github.com/assets/1"); }); 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("/contents/manifest.json")) 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" }] }); } @@ -68,4 +67,29 @@ describe("GitHub plugin distribution", () => { }; await expect(downloadGithubExtension("https://github.com/example/edgeever-plugin", request)).rejects.toThrow("missing main.js"); }); + + test("rejects release permissions that differ from the repository manifest", async () => { + const request = async (input) => { + const url = String(input); + if (url.endsWith("/contents/manifest.json")) 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: "manifest-asset" }, + { id: 2, name: "main.js", size: 128, url: "asset", browser_download_url: "main-asset" }, + ], + }); + } + return new Response(null, { status: 404 }); + }; + const releaseManifest = { ...manifest, permissions: [...manifest.permissions, "network"], networkHosts: ["api.example.com"] }; + const downloadAsset = async (_coordinates, asset) => new TextEncoder().encode( + asset.id === 1 ? JSON.stringify(releaseManifest) : "export default { activate() {} };", + ).buffer; + + await expect(downloadGithubExtension("https://github.com/example/edgeever-plugin", request, downloadAsset)) + .rejects.toThrow("does not match the repository manifest"); + }); }); diff --git a/apps/web/src/lib/plugins/github-plugin-distribution.ts b/apps/web/src/lib/plugins/github-plugin-distribution.ts index f9a3882c..1cbdce34 100644 --- a/apps/web/src/lib/plugins/github-plugin-distribution.ts +++ b/apps/web/src/lib/plugins/github-plugin-distribution.ts @@ -1,5 +1,6 @@ import { parseExtensionManifest, type ExtensionManifest, type PluginManifest } from "@edgeever/plugin-api"; import type { CachedPluginPackage } from "@/lib/plugins/plugin-package-store"; +import { api } from "@/lib/api"; const GITHUB_API_VERSION = "2022-11-28"; const MAX_MAIN_JS_BYTES = 5 * 1024 * 1024; @@ -20,12 +21,14 @@ export interface GithubDownloadedExtension { checksums: Partial; } -type GithubRepositoryResponse = { - default_branch?: string; - html_url?: string; -}; +export interface GithubRepositoryManifest { + manifest: ExtensionManifest; + manifestText: string; + manifestUrl: string; + repositoryUrl: string; +} -type GithubReleaseAsset = { +export type GithubReleaseAsset = { id: number; name: string; size: number; @@ -62,16 +65,6 @@ export const sha256Hex = async (value: string | ArrayBuffer) => { return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); }; -const requestJson = async (request: typeof fetch, url: string): Promise => { - 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; -}; - const findRelease = async (request: typeof fetch, coordinates: GithubRepositoryCoordinates, version: string) => { for (const tag of [version, `v${version}`]) { const response = await request( @@ -97,15 +90,27 @@ const requireAsset = (release: GithubReleaseResponse, name: string) => { return asset; }; -const downloadAsset = async (request: typeof fetch, asset: GithubReleaseAsset, maximumBytes: number) => { +type GithubAssetDownloader = ( + coordinates: GithubRepositoryCoordinates, + asset: GithubReleaseAsset, +) => Promise; + +const downloadGithubAssetThroughApi: GithubAssetDownloader = (coordinates, asset) => + api.downloadGithubPluginAsset( + coordinates.owner, + coordinates.repository, + asset.id, + asset.name as "manifest.json" | "main.js" | "styles.css", + ); + +const downloadAsset = async ( + coordinates: GithubRepositoryCoordinates, + asset: GithubReleaseAsset, + maximumBytes: number, + download: GithubAssetDownloader, +) => { 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(); + const buffer = await download(coordinates, asset); 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) { @@ -114,10 +119,26 @@ const downloadAsset = async (request: typeof fetch, asset: GithubReleaseAsset, m return { buffer, checksum }; }; +const canonicalize = (value: unknown): unknown => { + if (Array.isArray(value)) return value.map(canonicalize); + if (!value || typeof value !== "object") return value; + return Object.fromEntries( + Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entry]) => [key, canonicalize(entry)]) + ); +}; + +export const extensionManifestsEqual = (left: ExtensionManifest, right: ExtensionManifest) => + JSON.stringify(canonicalize(left)) === JSON.stringify(canonicalize(right)); + 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."); + if (!extensionManifestsEqual(repositoryManifest, releaseManifest)) { + throw new Error("Release manifest does not match the repository manifest."); + } }; const assertBundledEntry = (manifest: PluginManifest) => { @@ -125,21 +146,42 @@ const assertBundledEntry = (manifest: PluginManifest) => { 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 => { +export const loadGithubRepositoryManifest = async ( + input: string, + request: typeof fetch = window.fetch.bind(window), +): Promise => { 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(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" }); + const manifestUrl = `https://api.github.com/repos/${coordinates.owner}/${coordinates.repository}/contents/manifest.json`; + const manifestResponse = await request(manifestUrl, { + cache: "no-store", + credentials: "omit", + headers: { Accept: "application/vnd.github.raw+json", "X-GitHub-Api-Version": GITHUB_API_VERSION }, + }); 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); + const manifestText = await manifestResponse.text(); + return { + manifest: parseExtensionManifest(JSON.parse(manifestText) as unknown), + manifestText, + manifestUrl, + repositoryUrl: coordinates.repositoryUrl, + }; +}; + +export const downloadGithubExtension = async ( + input: string, + request: typeof fetch = window.fetch.bind(window), + downloadAssetBytes: GithubAssetDownloader = downloadGithubAssetThroughApi, +): Promise => { + 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 loadGithubRepositoryManifest(input, request); + const { manifest: repositoryManifest, manifestText: repositoryManifestText, manifestUrl } = repository; if (repositoryManifest.type === "theme") { return { manifest: repositoryManifest, manifestUrl, - repositoryUrl: repository.html_url ?? coordinates.repositoryUrl, + repositoryUrl: repository.repositoryUrl, releaseTag: null, pluginPackage: null, checksums: { manifestJson: await sha256Hex(repositoryManifestText) }, @@ -152,9 +194,9 @@ export const downloadGithubExtension = async (input: string, request: typeof fet 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), + downloadAsset(coordinates, manifestAsset, 256 * 1024, downloadAssetBytes), + downloadAsset(coordinates, mainAsset, MAX_MAIN_JS_BYTES, downloadAssetBytes), + stylesAsset ? downloadAsset(coordinates, stylesAsset, MAX_STYLES_CSS_BYTES, downloadAssetBytes) : Promise.resolve(null), ]); const releaseManifest = parseExtensionManifest(JSON.parse(new TextDecoder().decode(downloadedManifest.buffer)) as unknown); assertReleaseManifest(repositoryManifest, releaseManifest); @@ -164,7 +206,7 @@ export const downloadGithubExtension = async (input: string, request: typeof fet return { manifest: releaseManifest, manifestUrl: manifestAsset.browser_download_url, - repositoryUrl: repository.html_url ?? coordinates.repositoryUrl, + repositoryUrl: repository.repositoryUrl, releaseTag: release.tag_name, pluginPackage: { pluginId: releaseManifest.id, diff --git a/apps/web/src/lib/plugins/plugin-host.test.mjs b/apps/web/src/lib/plugins/plugin-host.test.mjs index 44c1a2ba..caa35f7c 100644 --- a/apps/web/src/lib/plugins/plugin-host.test.mjs +++ b/apps/web/src/lib/plugins/plugin-host.test.mjs @@ -173,4 +173,30 @@ describe("EdgeEverPluginHost", () => { expect(packages.has(manifest.id)).toBe(false); await host.dispose(); }); + + test("rejects a manifest that changed after update confirmation", async () => { + const confirmedManifest = { + type: "theme", + id: "org.edgeever.changed-theme", + name: "Changed theme", + version: "2.0.0", + themeApiVersion: "1", + modes: ["light"], + light: { "color.background": "#ffffff" }, + }; + globalThis.window.fetch = async () => Response.json({ + ...confirmedManifest, + light: { "color.background": "#000000" }, + }); + const host = new EdgeEverPluginHost({ repository, scope: "test" }); + + await expect(host.installFromManifestUrl( + "https://plugins.example/manifest.json", + undefined, + confirmedManifest, + )).rejects.toThrow("changed after update confirmation"); + + expect(host.getSnapshot().extensions).toHaveLength(0); + await host.dispose(); + }); }); diff --git a/apps/web/src/lib/plugins/plugin-host.ts b/apps/web/src/lib/plugins/plugin-host.ts index 50c0f368..c53077ed 100644 --- a/apps/web/src/lib/plugins/plugin-host.ts +++ b/apps/web/src/lib/plugins/plugin-host.ts @@ -20,7 +20,7 @@ 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"; +import { downloadGithubExtension, extensionManifestsEqual, 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"; @@ -212,6 +212,12 @@ const isAllowedNetworkHost = (hostname: string, allowedHosts: string[]) => const resolveManifestEntry = (manifestUrl: string, entry: string) => new URL(entry, manifestUrl).href; +const assertConfirmedManifest = (confirmedManifest: ExtensionManifest | undefined, downloadedManifest: ExtensionManifest) => { + if (confirmedManifest && !extensionManifestsEqual(confirmedManifest, downloadedManifest)) { + throw new Error("The extension manifest changed after update confirmation. Review the update again."); + } +}; + export class EdgeEverPluginHost { private readonly repository: EdgeEverRepository; private readonly scope: string; @@ -272,8 +278,9 @@ export class EdgeEverPluginHost { return this.installFromManifestUrl(input); } - async installFromGithubRepository(input: string, marketplaceEntry?: MarketplaceEntry) { + async installFromGithubRepository(input: string, marketplaceEntry?: MarketplaceEntry, confirmedManifest?: ExtensionManifest) { const downloaded = await downloadGithubExtension(input); + assertConfirmedManifest(confirmedManifest, downloaded.manifest); 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); @@ -288,14 +295,14 @@ export class EdgeEverPluginHost { return installed; } - async installMarketplaceEntry(entry: MarketplaceEntry) { + async installMarketplaceEntry(entry: MarketplaceEntry, confirmedManifest?: ExtensionManifest) { if (entry.distribution.type === "github") { - return this.installFromGithubRepository(entry.distribution.repositoryUrl, entry); + return this.installFromGithubRepository(entry.distribution.repositoryUrl, entry, confirmedManifest); } - return this.installFromManifestUrl(entry.distribution.manifestUrl, entry); + return this.installFromManifestUrl(entry.distribution.manifestUrl, entry, confirmedManifest); } - async installFromManifestUrl(input: string, marketplaceEntry?: MarketplaceEntry) { + async installFromManifestUrl(input: string, marketplaceEntry?: MarketplaceEntry, confirmedManifest?: ExtensionManifest) { 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."); @@ -304,6 +311,7 @@ export class EdgeEverPluginHost { 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); + assertConfirmedManifest(confirmedManifest, manifest); let pluginPackage: CachedPluginPackage | null = null; if (manifest.type === "plugin") { const entryUrl = resolveManifestEntry(manifestUrl.href, manifest.entry); diff --git a/apps/web/src/lib/plugins/plugin-marketplace.test.mjs b/apps/web/src/lib/plugins/plugin-marketplace.test.mjs index 8b3193ec..bd042bd1 100644 --- a/apps/web/src/lib/plugins/plugin-marketplace.test.mjs +++ b/apps/web/src/lib/plugins/plugin-marketplace.test.mjs @@ -5,6 +5,8 @@ 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()); + expect(registry.entries.map((entry) => entry.id)).not.toContain("org.edgeever.examples.recent-notes"); + expect(registry.entries.map((entry) => entry.id)).not.toContain("org.edgeever.themes.nord-emerald"); for (const entry of registry.entries) { expect(entry.distribution.type).toBe("manifest"); if (entry.distribution.type !== "manifest") continue; diff --git a/apps/web/src/lib/plugins/plugin-updates.test.mjs b/apps/web/src/lib/plugins/plugin-updates.test.mjs new file mode 100644 index 00000000..efb57ac6 --- /dev/null +++ b/apps/web/src/lib/plugins/plugin-updates.test.mjs @@ -0,0 +1,75 @@ +import { describe, expect, test } from "bun:test"; +import { checkInstalledExtensionUpdate, checkPluginUpdates } from "./plugin-updates.ts"; + +const pluginManifest = { + type: "plugin", + id: "org.edgeever.update-test", + name: "Update Test", + version: "1.0.0", + apiVersion: "1", + entry: "./main.js", + permissions: ["ui:notices"], +}; + +const installed = (overrides = {}) => ({ + manifestUrl: "https://example.com/plugin/manifest.json", + manifest: pluginManifest, + enabled: true, + installedAt: "2026-08-16T00:00:00.000Z", + error: null, + source: { kind: "manifest", verified: false }, + ...overrides, +}); + +describe("plugin update checks", () => { + test("detects a manifest update and reports added access", async () => { + const request = async () => Response.json({ + ...pluginManifest, + version: "1.1.0", + permissions: ["ui:notices", "network"], + networkHosts: ["api.example.com"], + }); + + const update = await checkInstalledExtensionUpdate(installed(), [], request); + + expect(update?.latestVersion).toBe("1.1.0"); + expect(update?.addedPermissions).toEqual(["network"]); + expect(update?.addedNetworkHosts).toEqual(["api.example.com"]); + }); + + test("uses the verified registry version for marketplace installs", async () => { + const entry = { + id: pluginManifest.id, + name: pluginManifest.name, + description: "Verified plugin", + author: "EdgeEver", + category: "Productivity", + repositoryUrl: "https://github.com/example/plugin", + distribution: { type: "manifest", manifestUrl: "https://example.com/verified/manifest.json" }, + verification: { version: "1.2.0" }, + }; + const request = async () => Response.json({ ...pluginManifest, version: "1.2.0" }); + + const update = await checkInstalledExtensionUpdate(installed({ source: { kind: "marketplace", verified: true } }), [entry], request); + + expect(update?.latestVersion).toBe("1.2.0"); + expect(update?.marketplaceEntry).toEqual(entry); + }); + + test("does not offer the same or an older version", async () => { + const request = async () => Response.json(pluginManifest); + expect(await checkInstalledExtensionUpdate(installed(), [], request)).toBeNull(); + }); + + test("isolates a failed source while checking other extensions", async () => { + const broken = installed({ manifest: { ...pluginManifest, id: "org.edgeever.broken" }, manifestUrl: "https://broken.example/manifest.json" }); + const request = async (input) => String(input).includes("broken") + ? new Response(null, { status: 503 }) + : Response.json({ ...pluginManifest, version: "2.0.0" }); + + const result = await checkPluginUpdates([broken, installed()], [], request); + + expect(result.updates.map((update) => update.pluginId)).toEqual([pluginManifest.id]); + expect(result.errors["org.edgeever.broken"]).toContain("503"); + }); +}); diff --git a/apps/web/src/lib/plugins/plugin-updates.ts b/apps/web/src/lib/plugins/plugin-updates.ts new file mode 100644 index 00000000..0fe8ed0c --- /dev/null +++ b/apps/web/src/lib/plugins/plugin-updates.ts @@ -0,0 +1,103 @@ +import { parseExtensionManifest, type ExtensionManifest, type MarketplaceEntry, type PluginPermission } from "@edgeever/plugin-api"; +import type { InstalledExtension } from "@/lib/plugins/plugin-host"; +import { loadGithubRepositoryManifest } from "@/lib/plugins/github-plugin-distribution"; +import { isVersionOutdated } from "@/lib/version-check"; + +export interface PluginUpdateInfo { + pluginId: string; + currentVersion: string; + latestVersion: string; + latestManifest: ExtensionManifest; + addedPermissions: PluginPermission[]; + addedNetworkHosts: string[]; + marketplaceEntry?: MarketplaceEntry; +} + +export interface PluginUpdateCheckResult { + updates: PluginUpdateInfo[]; + errors: Record; +} + +const fetchManifest = async (url: string, request: typeof fetch) => { + const response = await request(url, { cache: "no-store", credentials: "omit" }); + if (!response.ok) throw new Error(`Manifest request failed with HTTP ${response.status}.`); + return parseExtensionManifest(await response.json()); +}; + +const loadMarketplaceManifest = async (entry: MarketplaceEntry, request: typeof fetch) => { + if (entry.distribution.type === "github") { + return (await loadGithubRepositoryManifest(entry.distribution.repositoryUrl, request)).manifest; + } + return fetchManifest(entry.distribution.manifestUrl, request); +}; + +const assertCompatibleUpdate = (extension: InstalledExtension, manifest: ExtensionManifest) => { + if (manifest.id !== extension.manifest.id) throw new Error("Update manifest plugin id does not match the installed extension."); + if (manifest.type !== extension.manifest.type) throw new Error("Update manifest type does not match the installed extension."); +}; + +const addedValues = (current: readonly T[] | undefined, latest: readonly T[] | undefined) => { + const existing = new Set(current ?? []); + return [...new Set(latest ?? [])].filter((value) => !existing.has(value)); +}; + +export const checkInstalledExtensionUpdate = async ( + extension: InstalledExtension, + marketplaceEntries: MarketplaceEntry[], + request: typeof fetch = window.fetch.bind(window), +): Promise => { + const marketplaceEntry = extension.source.kind === "marketplace" + ? marketplaceEntries.find((entry) => entry.id === extension.manifest.id) + : undefined; + + let latestManifest: ExtensionManifest; + if (extension.source.kind === "marketplace") { + if (!marketplaceEntry || !isVersionOutdated(extension.manifest.version, marketplaceEntry.verification.version)) return null; + latestManifest = await loadMarketplaceManifest(marketplaceEntry, request); + if (latestManifest.version !== marketplaceEntry.verification.version) { + throw new Error("Marketplace version does not match the distributed manifest."); + } + } else if (extension.source.kind === "github") { + if (!extension.source.repositoryUrl) throw new Error("Installed GitHub extension is missing its repository URL."); + latestManifest = (await loadGithubRepositoryManifest(extension.source.repositoryUrl, request)).manifest; + } else { + latestManifest = await fetchManifest(extension.manifestUrl, request); + } + + assertCompatibleUpdate(extension, latestManifest); + if (!isVersionOutdated(extension.manifest.version, latestManifest.version)) return null; + + const currentPlugin = extension.manifest.type === "plugin" ? extension.manifest : null; + const latestPlugin = latestManifest.type === "plugin" ? latestManifest : null; + return { + pluginId: extension.manifest.id, + currentVersion: extension.manifest.version, + latestVersion: latestManifest.version, + latestManifest, + addedPermissions: addedValues(currentPlugin?.permissions, latestPlugin?.permissions), + addedNetworkHosts: addedValues(currentPlugin?.networkHosts, latestPlugin?.networkHosts), + ...(marketplaceEntry ? { marketplaceEntry } : {}), + }; +}; + +export const checkPluginUpdates = async ( + extensions: InstalledExtension[], + marketplaceEntries: MarketplaceEntry[], + request: typeof fetch = window.fetch.bind(window), +): Promise => { + const settled = await Promise.allSettled( + extensions.map((extension) => checkInstalledExtensionUpdate(extension, marketplaceEntries, request)) + ); + const updates: PluginUpdateInfo[] = []; + const errors: Record = {}; + settled.forEach((result, index) => { + const pluginId = extensions[index]?.manifest.id; + if (!pluginId) return; + if (result.status === "fulfilled") { + if (result.value) updates.push(result.value); + return; + } + errors[pluginId] = result.reason instanceof Error ? result.reason.message : String(result.reason); + }); + return { updates, errors }; +}; diff --git a/apps/web/src/lib/version-check.test.mjs b/apps/web/src/lib/version-check.test.mjs index 92a26389..1f1f785b 100644 --- a/apps/web/src/lib/version-check.test.mjs +++ b/apps/web/src/lib/version-check.test.mjs @@ -26,5 +26,8 @@ describe("platform release version checks", () => { test("compares semantic release components and ignores build metadata", () => { expect(isVersionOutdated("1.6.49+12", "1.6.50")).toBe(true); expect(isVersionOutdated("1.6.50+3", "1.6.50")).toBe(false); + expect(isVersionOutdated("1.6.50-beta.2", "1.6.50-beta.10")).toBe(true); + expect(isVersionOutdated("1.6.50-beta.10", "1.6.50")).toBe(true); + expect(isVersionOutdated("1.6.50", "1.6.50-beta.10")).toBe(false); }); }); diff --git a/apps/web/src/lib/version-check.ts b/apps/web/src/lib/version-check.ts index f6fca217..8b38ae7c 100644 --- a/apps/web/src/lib/version-check.ts +++ b/apps/web/src/lib/version-check.ts @@ -27,8 +27,11 @@ export const findDesktopReleaseVersion = (assetNames: string[]) => { }; const parseVersion = (value: string) => { - const match = value.match(/v?(\d+)\.(\d+)\.(\d+)/i); - return match ? match.slice(1).map(Number) : null; + const match = value.match(/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/i); + return match ? { + core: match.slice(1, 4).map(Number), + prerelease: match[4]?.split(".") ?? null, + } : null; }; export const isVersionOutdated = (currentVersion: string, latestVersion: string) => { @@ -37,9 +40,24 @@ export const isVersionOutdated = (currentVersion: string, latestVersion: string) if (!current || !latest) return false; for (let index = 0; index < 3; index += 1) { - if (current[index] !== latest[index]) return current[index] < latest[index]; + if (current.core[index] !== latest.core[index]) return current.core[index] < latest.core[index]; + } + if (!current.prerelease) return false; + if (!latest.prerelease) return true; + const length = Math.max(current.prerelease.length, latest.prerelease.length); + for (let index = 0; index < length; index += 1) { + const currentPart = current.prerelease[index]; + const latestPart = latest.prerelease[index]; + if (currentPart === undefined) return true; + if (latestPart === undefined) return false; + if (currentPart === latestPart) continue; + const currentNumber = /^\d+$/.test(currentPart) ? Number(currentPart) : null; + const latestNumber = /^\d+$/.test(latestPart) ? Number(latestPart) : null; + if (currentNumber !== null && latestNumber !== null) return currentNumber < latestNumber; + if (currentNumber !== null) return true; + if (latestNumber !== null) return false; + return currentPart.localeCompare(latestPart) < 0; } - return false; }; diff --git a/docs/plugin-development.md b/docs/plugin-development.md index 34726014..bd4693f3 100644 --- a/docs/plugin-development.md +++ b/docs/plugin-development.md @@ -42,6 +42,8 @@ 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. +EdgeEver checks for updates when the Plugin Marketplace opens, when the window regains focus, and every 30 minutes, but never installs silently. The user must click Update and confirm. If a new version adds plugin permissions or network hosts, the confirmation lists the additional access. For GitHub distribution, the Release `manifest.json` must exactly match the default-branch manifest used for the update prompt or installation is rejected. Marketplace installs only follow newer versions verified in the Registry. + Users can freely install without marketplace admission by pasting this into the standalone Plugin Marketplace page: ```text diff --git a/docs/plugin-development.zh-CN.md b/docs/plugin-development.zh-CN.md index 2df37b79..da2bd079 100644 --- a/docs/plugin-development.zh-CN.md +++ b/docs/plugin-development.zh-CN.md @@ -42,6 +42,8 @@ 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。 +EdgeEver 会在插件市场页面打开、窗口重新获得焦点及每 30 分钟检查一次更新,但不会静默安装。用户必须点击「更新」并确认;如果新版新增插件权限或网络域名,确认框会明确列出新增访问范围。GitHub 分发的 Release `manifest.json` 必须与默认分支中用于提示更新的 Manifest 完全一致,否则安装会被拒绝。市场安装只跟随 Registry 中已经验证的新版本。 + 用户在独立的「插件市场」页面中粘贴以下地址即可自由安装,无需经过官方市场收录: ```text diff --git a/packages/shared/src/i18n/en-US.ts b/packages/shared/src/i18n/en-US.ts index 40cac39b..d9f60575 100644 --- a/packages/shared/src/i18n/en-US.ts +++ b/packages/shared/src/i18n/en-US.ts @@ -270,7 +270,32 @@ export const enUS = { 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.", + details: { + title: "Plugin details", + open: "View details for {{name}}", + notFound: "This plugin was not found. It may have been uninstalled.", + type: "Type", + version: "Version", + source: "Source", + installedAt: "Installed", + permissions: "Permissions", + networkHosts: "Network hosts", + }, + updates: { + check: "Check for updates", + found: "Updates found: {{count}}", + upToDate: "Up to date", + checkFailed: "Update check failed: {{message}}", + available: "Update to v{{version}}", + update: "Update", + updating: "Updating…", + confirmTitle: "Update {{name}}?", + confirmDescription: "EdgeEver never installs updates automatically. Confirm to download and replace the current version while preserving its enabled state.", + addedAccessDescription: "This version requests additional access. Review these changes before updating.", + addedPermissions: "New permissions", + addedNetworkHosts: "New network hosts", + allowAndUpdate: "Allow and update", + }, noticeTitle: "Plugin", panel: "Plugin panel", panelDescription: "This interface is provided by an enabled trusted plugin.", @@ -282,7 +307,6 @@ export const enUS = { }, 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", diff --git a/packages/shared/src/i18n/zh-CN.ts b/packages/shared/src/i18n/zh-CN.ts index d32b0747..950bb719 100644 --- a/packages/shared/src/i18n/zh-CN.ts +++ b/packages/shared/src/i18n/zh-CN.ts @@ -270,7 +270,32 @@ export const zhCN = { empty: "当前设备尚未安装插件或主题。", toggle: "启用或停用 {{name}}", uninstall: "卸载", - localOnly: "P0 扩展仅安装在当前设备,并且只在 EdgeEver 打开期间运行;暂不包含后台定时任务。", + details: { + title: "插件详情", + open: "查看 {{name}} 详情", + notFound: "未找到这个插件,可能已被卸载。", + type: "类型", + version: "版本", + source: "来源", + installedAt: "安装时间", + permissions: "权限", + networkHosts: "网络域名", + }, + updates: { + check: "检查更新", + found: "发现 {{count}} 个更新", + upToDate: "已是最新版本", + checkFailed: "检查更新失败:{{message}}", + available: "可更新至 v{{version}}", + update: "更新", + updating: "更新中…", + confirmTitle: "更新 {{name}}?", + confirmDescription: "EdgeEver 不会自动安装更新。确认后将下载并替换当前版本,原启用状态保持不变。", + addedAccessDescription: "此版本请求了新的访问权限。请确认这些变化后再更新。", + addedPermissions: "新增权限", + addedNetworkHosts: "新增网络域名", + allowAndUpdate: "允许并更新", + }, noticeTitle: "插件", panel: "插件面板", panelDescription: "此界面由已启用的受信任插件提供。", @@ -282,7 +307,6 @@ export const zhCN = { }, marketplace: { title: "插件市场", - description: "市场只提供经过校验的索引,插件仍由开发者通过 GitHub 或公开地址分发。", refresh: "刷新插件市场", loading: "正在加载插件市场…", verified: "EdgeEver 已验证",