feat: add plugin updates and details

This commit is contained in:
tianma-if committed 2026-08-16 19:34:16 +08:00
1 parent c657e383a0
commit dc7626843a
24 files changed
+998 -170

No files matched your search

+2
View File
@@ -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),
@@ -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");
});
});
@@ -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<AppEnv>) => {
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,
);
}
});
};
+1 -39
View File
@@ -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": []
}
+1
View File
@@ -228,6 +228,7 @@ export const App = () => {
<Route path="/" element={<AuthenticatedWorkspace />} />
<Route path="/settings" element={<AuthenticatedWorkspace />} />
<Route path="/plugins" element={<AuthenticatedWorkspace />} />
<Route path="/plugins/:pluginId" element={<AuthenticatedWorkspace />} />
<Route path="/templates" element={<AuthenticatedWorkspace />} />
<Route path="/ai-prompts" element={<AuthenticatedWorkspace />} />
<Route path="*" element={<Navigate to="/" replace />} />
@@ -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 (
<div className="flex h-full min-h-0 min-w-0 flex-col overflow-hidden bg-slate-50">
@@ -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"
>
<ChevronLeft className="h-5 w-5 text-slate-500" />
@@ -40,7 +50,12 @@ export const PluginMarketplacePane = ({
<main className="min-h-0 flex-1 overflow-y-auto px-4 py-5 sm:px-6 lg:py-7">
<m.div className="mx-auto w-full max-w-5xl" {...contentEnterMotion}>
<PluginManagerCard host={host} />
<PluginManagerCard
host={host}
selectedPluginId={pluginId}
onOpenPlugin={(id) => navigate(`/plugins/${encodeURIComponent(id)}`)}
onClosePlugin={() => navigate("/plugins")}
/>
</m.div>
</main>
</div>
@@ -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 (
<Dialog open={true} onOpenChange={(open) => { if (!open && !isUpdating) onCancel(); }}>
<DialogContent className="max-w-md overflow-hidden rounded-lg border border-slate-200 bg-white p-0 shadow-lg">
<DialogHeader className="flex flex-row items-start gap-4 border-b border-slate-200 px-5 py-5 text-left">
<span className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-full ${hasAddedAccess ? "bg-amber-50 text-amber-700" : "bg-emerald-50 text-emerald-700"}`}>
{hasAddedAccess ? <AlertTriangle className="h-5 w-5" /> : <ShieldCheck className="h-5 w-5" />}
</span>
<div className="min-w-0 flex-1">
<DialogTitle className="text-base font-semibold text-slate-950">
{t("plugins.updates.confirmTitle", { name: update.latestManifest.name })}
</DialogTitle>
<DialogDescription className="mt-1 flex items-center gap-2 text-sm text-slate-500">
<span>v{update.currentVersion}</span>
<ArrowRight className="h-3.5 w-3.5" />
<span>v{update.latestVersion}</span>
</DialogDescription>
</div>
</DialogHeader>
<div className="grid gap-3 px-5 py-4 text-sm">
<p className="leading-6 text-slate-600">
{hasAddedAccess ? t("plugins.updates.addedAccessDescription") : t("plugins.updates.confirmDescription")}
</p>
{update.addedPermissions.length > 0 ? (
<div>
<div className="mb-2 text-xs font-semibold text-slate-700">{t("plugins.updates.addedPermissions")}</div>
<div className="flex flex-wrap gap-1.5">
{update.addedPermissions.map((permission) => (
<span key={permission} className="rounded-full bg-amber-50 px-2 py-1 text-xs text-amber-800">
{permissionLabel(permission)}
</span>
))}
</div>
</div>
) : null}
{update.addedNetworkHosts.length > 0 ? (
<div>
<div className="mb-2 text-xs font-semibold text-slate-700">{t("plugins.updates.addedNetworkHosts")}</div>
<div className="flex flex-wrap gap-1.5">
{update.addedNetworkHosts.map((hostname) => (
<span key={hostname} className="rounded-full bg-amber-50 px-2 py-1 font-mono text-xs text-amber-800">{hostname}</span>
))}
</div>
</div>
) : null}
</div>
<DialogFooter className="flex flex-col-reverse gap-2 border-t border-slate-100 bg-slate-50/50 px-5 py-4 sm:flex-row sm:justify-end">
<Button variant="outline" onClick={onCancel} disabled={isUpdating}>{t("common.cancel")}</Button>
<Button onClick={onConfirm} disabled={isUpdating}>
{isUpdating
? t("plugins.updates.updating")
: hasAddedAccess
? t("plugins.updates.allowAndUpdate")
: t("plugins.updates.update")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -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 (
<div className="grid gap-5">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-xl font-semibold text-slate-950">{manifest.name}</h2>
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-slate-500">{manifest.type}</span>
{update ? (
<span className="rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-semibold text-emerald-700">
{t("plugins.updates.available", { version: update.latestVersion })}
</span>
) : null}
</div>
{manifest.description ? <p className="mt-2 max-w-3xl text-sm leading-6 text-slate-600">{manifest.description}</p> : null}
</div>
<Switch
aria-label={t("plugins.toggle", { name: manifest.name })}
checked={extension.enabled}
disabled={pendingId === id}
onCheckedChange={onToggle}
/>
</div>
<dl className="grid gap-3 rounded-lg bg-slate-50 p-4 text-sm sm:grid-cols-2 lg:grid-cols-4">
{[
[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]) => (
<div key={label} className="min-w-0">
<dt className="text-xs text-slate-400">{label}</dt>
<dd className="mt-1 truncate font-medium text-slate-700">{value}</dd>
</div>
))}
</dl>
{extension.source.repositoryUrl ? (
<a className="inline-flex w-fit max-w-full items-center gap-2 text-sm text-slate-500 hover:text-emerald-700" href={extension.source.repositoryUrl} target="_blank" rel="noreferrer">
<GitHubMark className="h-4 w-4 shrink-0" />
<span className="truncate">{extension.source.repositoryUrl.replace("https://github.com/", "")}</span>
<ExternalLink className="h-3.5 w-3.5 shrink-0" />
</a>
) : null}
{manifest.type === "plugin" && manifest.permissions.length > 0 ? (
<section>
<h3 className="text-xs font-semibold text-slate-700">{t("plugins.details.permissions")}</h3>
<div className="mt-2 flex flex-wrap gap-1.5">
{manifest.permissions.map((permission) => (
<span key={permission} className="rounded-full bg-slate-100 px-2 py-1 text-xs text-slate-600">{permissionLabel(permission)}</span>
))}
</div>
</section>
) : null}
{manifest.type === "plugin" && manifest.networkHosts?.length ? (
<section>
<h3 className="text-xs font-semibold text-slate-700">{t("plugins.details.networkHosts")}</h3>
<div className="mt-2 flex flex-wrap gap-1.5">
{manifest.networkHosts.map((hostname) => (
<span key={hostname} className="rounded-full bg-slate-100 px-2 py-1 font-mono text-xs text-slate-600">{hostname}</span>
))}
</div>
</section>
) : null}
{extension.error ? <div className="text-sm text-rose-600">{extension.error}</div> : null}
<div className="flex flex-wrap items-center gap-2 border-t border-slate-100 pt-4">
{update ? (
<Button size="sm" className="gap-1.5" disabled={pendingId === `update:${id}`} onClick={onUpdate}>
<Download className="h-3.5 w-3.5" />
{t("plugins.updates.update")}
</Button>
) : null}
{commands.map((command) => (
<Button key={command.id} size="sm" variant="outline" className="gap-1.5" disabled={pendingId === `${id}:${command.id}`} onClick={() => onRunCommand(command)}>
<Play className="h-3.5 w-3.5" />
{command.title}
</Button>
))}
{panels.map((panel) => (
<Button key={panel.id} size="sm" variant="outline" className="gap-1.5" onClick={() => onOpenPanel(panel)}>
<PanelRightOpen className="h-3.5 w-3.5" />
{panel.title}
</Button>
))}
<Button size="sm" variant="ghost" className="ml-auto gap-1.5 text-rose-600 hover:bg-rose-50 hover:text-rose-700" disabled={pendingId === `remove:${id}`} onClick={onUninstall}>
<Trash2 className="h-3.5 w-3.5" />
{t("plugins.uninstall")}
</Button>
</div>
</div>
);
};
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<string | null>(null);
const [installing, setInstalling] = useState(false);
const [manuallyChecking, setManuallyChecking] = useState(false);
const [lastManualCheckCount, setLastManualCheckCount] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const [activePanel, setActivePanel] = useState<RegisteredPluginPanel | null>(null);
const [pendingUpdate, setPendingUpdate] = useState<PluginUpdateInfo | null>(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 (
<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" />
{t("plugins.title")}
{selectedPluginId ? t("plugins.details.title") : t("plugins.title")}
</CardTitle>
<Button asChild variant="ghost" size="sm" className="h-8 shrink-0 gap-1.5 px-2 text-slate-600">
<a href={developerDocsUrl} target="_blank" rel="noreferrer" aria-label={t("plugins.developerDocs")}>
<BookOpen className="h-4 w-4" />
<span className="hidden sm:inline">{t("plugins.developerDocs")}</span>
<ExternalLink className="hidden h-3.5 w-3.5 sm:block" />
</a>
</Button>
<div className="flex items-center gap-1">
{snapshot.extensions.length > 0 ? (
<Button
variant="ghost"
size="sm"
className="h-8 gap-1.5 px-2 text-slate-600"
aria-label={t("plugins.updates.check")}
disabled={manuallyChecking || updateQuery.isFetching}
onClick={() => void checkForUpdates()}
>
<RefreshCw className={`h-3.5 w-3.5 ${manuallyChecking || updateQuery.isFetching ? "animate-spin" : ""}`} />
<span className="hidden sm:inline">
{lastManualCheckCount === null
? t("plugins.updates.check")
: lastManualCheckCount === 0
? t("plugins.updates.upToDate")
: t("plugins.updates.found", { count: lastManualCheckCount })}
</span>
{lastManualCheckCount === null && (updateQuery.data?.updates.length ?? 0) > 0 ? (
<span className="rounded-full bg-emerald-100 px-1.5 py-0.5 text-[10px] font-semibold text-emerald-700">
{updateQuery.data?.updates.length}
</span>
) : null}
</Button>
) : null}
<Button asChild variant="ghost" size="sm" className="h-8 shrink-0 gap-1.5 px-2 text-slate-600">
<a href={developerDocsUrl} target="_blank" rel="noreferrer" aria-label={t("plugins.developerDocs")}>
<BookOpen className="h-4 w-4" />
<span className="hidden sm:inline">{t("plugins.developerDocs")}</span>
<ExternalLink className="hidden h-3.5 w-3.5 sm:block" />
</a>
</Button>
</div>
</div>
</CardHeader>
<CardContent className="grid gap-4 p-4 pt-0 sm:px-5 sm:pb-5">
<section className="grid gap-3 rounded-xl border border-emerald-200 bg-emerald-50/40 p-3.5">
<div className="flex items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2 text-sm font-semibold text-slate-900">
<Store className="h-4 w-4 text-emerald-700" />
{t("plugins.marketplace.title")}
</div>
<p className="mt-1 text-xs leading-5 text-slate-500">{t("plugins.marketplace.description")}</p>
</div>
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
aria-label={t("plugins.marketplace.refresh")}
disabled={marketplaceQuery.isFetching}
onClick={() => void marketplaceQuery.refetch()}
>
<RefreshCw className={`h-3.5 w-3.5 ${marketplaceQuery.isFetching ? "animate-spin" : ""}`} />
</Button>
</div>
{error ? <div role="alert" className="rounded-lg border border-rose-200 bg-rose-50 p-3 text-xs text-rose-700">{error}</div> : null}
{marketplaceQuery.isLoading ? (
<div className="py-4 text-center text-xs text-slate-500">{t("plugins.marketplace.loading")}</div>
) : marketplaceQuery.error ? (
<div role="alert" className="rounded-lg border border-rose-200 bg-rose-50 p-3 text-xs text-rose-700">
{marketplaceQuery.error instanceof Error ? marketplaceQuery.error.message : String(marketplaceQuery.error)}
</div>
{selectedPluginId ? (
selectedExtension ? (
<PluginDetailView
extension={selectedExtension}
update={updateQuery.data?.updates.find((update) => 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?.();
})}
/>
) : (
<div className="grid gap-2 md:grid-cols-2">
{(marketplaceQuery.data?.entries ?? []).map((entry) => {
const installed = snapshot.extensions.find((extension) => extension.manifest.id === entry.id);
const currentVerified = installed?.source.verified && installed.manifest.version === entry.verification.version;
const actionId = `marketplace:${entry.id}`;
return (
<article key={entry.id} className="flex min-w-0 flex-col rounded-lg border border-emerald-100 bg-white p-3">
<div className="rounded-lg border border-dashed border-slate-200 p-8 text-center text-sm text-slate-500">
{t("plugins.details.notFound")}
</div>
)
) : (
<>
{(marketplaceQuery.data?.entries.length ?? 0) > 0 ? (
<div className="grid gap-2 md:grid-cols-2">
{(marketplaceQuery.data?.entries ?? []).map((entry) => {
const installed = snapshot.extensions.find((extension) => extension.manifest.id === entry.id);
const currentVerified = installed?.source.verified && installed.manifest.version === entry.verification.version;
const marketplaceUpdate = updateQuery.data?.updates.find((update) => update.pluginId === entry.id && update.marketplaceEntry);
const actionId = `marketplace:${entry.id}`;
return (
<article key={entry.id} className="flex min-w-0 flex-col rounded-lg border border-emerald-100 bg-white p-3">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="flex items-center gap-1.5">
@@ -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); });
}}
>
<Download className="h-3.5 w-3.5" />
{pendingId === actionId
? t("plugins.installing")
: currentVerified
: marketplaceUpdate
? t("plugins.updates.update")
: currentVerified
? t("plugins.marketplace.installed")
: installed
? t("plugins.marketplace.installVerified")
: t("plugins.install")}
</Button>
</article>
);
})}
</div>
)}
</section>
</article>
);
})}
</div>
) : null}
<div className="flex flex-col gap-2 sm:flex-row">
<Input
@@ -169,38 +403,58 @@ export const PluginManagerCard = ({ host }: { host: EdgeEverPluginHost }) => {
</Button>
</div>
{error ? <div role="alert" className="rounded-lg border border-rose-200 bg-rose-50 p-3 text-xs text-rose-700">{error}</div> : null}
{snapshot.extensions.length === 0 ? (
<div className="rounded-lg border border-dashed border-slate-200 p-5 text-center text-xs text-slate-500">
{t("plugins.empty")}
</div>
) : (
<div className="grid gap-2">
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
{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 (
<section key={id} className="rounded-lg border border-slate-200 bg-white p-3.5">
<section
key={id}
role="link"
tabIndex={0}
aria-label={t("plugins.details.open", { name: extension.manifest.name })}
className="flex min-w-0 cursor-pointer flex-col rounded-lg border border-slate-200 bg-white p-3 transition-colors hover:border-emerald-300 hover:bg-emerald-50/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/70"
onClick={(event) => {
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);
}
}}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1.5">
<span className="truncate text-sm font-semibold text-slate-900">{extension.manifest.name}</span>
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-slate-500">
{extension.manifest.type}
</span>
<span className="text-[11px] text-slate-400">v{extension.manifest.version}</span>
{availableUpdate ? (
<span className="rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-semibold text-emerald-700">
{t("plugins.updates.available", { version: availableUpdate.latestVersion })}
</span>
) : null}
<span className={`inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[10px] ${extension.source.verified ? "bg-emerald-50 text-emerald-700" : "bg-amber-50 text-amber-700"}`}>
{extension.source.verified ? <BadgeCheck className="h-3 w-3" /> : extension.source.kind === "github" ? <GitHubMark className="h-3 w-3" /> : null}
{t(`plugins.sources.${extension.source.verified ? "verified" : extension.source.kind}`)}
</span>
</div>
{extension.manifest.description ? <p className="mt-1 text-xs leading-5 text-slate-500">{extension.manifest.description}</p> : null}
{extension.manifest.description ? <p className="mt-1 line-clamp-2 text-xs leading-5 text-slate-500">{extension.manifest.description}</p> : null}
{extension.source.repositoryUrl ? (
<a className="mt-1 inline-flex items-center gap-1 text-[11px] text-slate-400 hover:text-emerald-700" href={extension.source.repositoryUrl} target="_blank" rel="noreferrer">
<GitHubMark className="h-3 w-3" />
{extension.source.repositoryUrl.replace("https://github.com/", "")}
<a className="mt-1 flex max-w-full items-center gap-1 text-[11px] text-slate-400 hover:text-emerald-700" href={extension.source.repositoryUrl} target="_blank" rel="noreferrer">
<GitHubMark className="h-3 w-3 shrink-0" />
<span className="truncate">{extension.source.repositoryUrl.replace("https://github.com/", "")}</span>
</a>
) : null}
</div>
@@ -214,17 +468,33 @@ export const PluginManagerCard = ({ host }: { host: EdgeEverPluginHost }) => {
{extension.manifest.type === "plugin" && extension.manifest.permissions.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-1">
{extension.manifest.permissions.map((permission) => (
{extension.manifest.permissions.slice(0, 3).map((permission) => (
<span key={permission} className="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] text-slate-600">
{permissionLabel(permission)}
</span>
))}
{extension.manifest.permissions.length > 3 ? (
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] text-slate-600">
+{extension.manifest.permissions.length - 3}
</span>
) : null}
</div>
) : null}
{extension.error ? <div className="mt-2 text-xs text-rose-600">{extension.error}</div> : null}
<div className="mt-3 flex flex-wrap items-center gap-2">
<div className="mt-auto flex flex-wrap items-center gap-1.5 pt-3">
{availableUpdate ? (
<Button
size="sm"
className="h-8 gap-1.5 text-xs"
disabled={pendingId === `update:${id}`}
onClick={() => setPendingUpdate(availableUpdate)}
>
<Download className="h-3.5 w-3.5" />
{t("plugins.updates.update")}
</Button>
) : null}
{commands.map((command) => (
<Button
key={command.id}
@@ -266,12 +536,18 @@ export const PluginManagerCard = ({ host }: { host: EdgeEverPluginHost }) => {
})}
</div>
)}
</>
)}
<div className="flex items-start gap-2 rounded-lg bg-slate-100 p-3 text-xs leading-5 text-slate-600">
<Boxes className="mt-0.5 h-4 w-4 shrink-0 text-slate-400" />
<span>{t("plugins.localOnly")}</span>
</div>
<PluginPanelDialog host={host} panel={activePanel} onClose={() => setActivePanel(null)} />
{pendingUpdate ? (
<PluginUpdateDialog
update={pendingUpdate}
isUpdating={pendingId === `update:${pendingUpdate.pluginId}`}
onCancel={() => setPendingUpdate(null)}
onConfirm={() => void run(`update:${pendingUpdate.pluginId}`, () => applyUpdate(pendingUpdate))}
/>
) : null}
</CardContent>
</Card>
);
@@ -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);
});
+1 -1
View File
@@ -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,
+26
View File
@@ -431,6 +431,23 @@ const request = async <T>(path: string, init?: RequestInit): Promise<T> => {
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<AuthSession>("/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);
@@ -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");
});
});
@@ -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<CachedPluginPackage["checksums"]>;
}
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 <T>(request: typeof fetch, url: string): Promise<T> => {
const response = await request(url, {
cache: "no-store",
credentials: "omit",
headers: { Accept: "application/vnd.github+json", "X-GitHub-Api-Version": GITHUB_API_VERSION },
});
if (!response.ok) throw new Error(`GitHub request failed with HTTP ${response.status}.`);
return response.json() as Promise<T>;
};
const findRelease = async (request: typeof fetch, coordinates: GithubRepositoryCoordinates, version: string) => {
for (const tag of [version, `v${version}`]) {
const response = await request(
@@ -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<ArrayBuffer>;
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<string, unknown>)
.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<GithubDownloadedExtension> => {
export const loadGithubRepositoryManifest = async (
input: string,
request: typeof fetch = window.fetch.bind(window),
): Promise<GithubRepositoryManifest> => {
const coordinates = parseGithubRepositoryUrl(input);
if (!coordinates) throw new Error("Enter a public GitHub repository URL such as https://github.com/owner/repository.");
const repository = await requestJson<GithubRepositoryResponse>(request, `https://api.github.com/repos/${coordinates.owner}/${coordinates.repository}`);
if (!repository.default_branch) throw new Error("GitHub did not return a default branch for this repository.");
const manifestUrl = `https://raw.githubusercontent.com/${coordinates.owner}/${coordinates.repository}/${encodeURIComponent(repository.default_branch)}/manifest.json`;
const manifestResponse = await request(manifestUrl, { cache: "no-store", credentials: "omit" });
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<GithubDownloadedExtension> => {
const coordinates = parseGithubRepositoryUrl(input);
if (!coordinates) throw new Error("Enter a public GitHub repository URL such as https://github.com/owner/repository.");
const repository = await 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,
@@ -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();
});
});
+14 -6
View File
@@ -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);
@@ -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;
@@ -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");
});
});
+103
View File
@@ -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<string, string>;
}
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 = <T extends string>(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<PluginUpdateInfo | null> => {
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<PluginUpdateCheckResult> => {
const settled = await Promise.allSettled(
extensions.map((extension) => checkInstalledExtensionUpdate(extension, marketplaceEntries, request))
);
const updates: PluginUpdateInfo[] = [];
const errors: Record<string, string> = {};
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 };
};
+3
View File
@@ -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);
});
});
+22 -4
View File
@@ -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;
};
+2
View File
@@ -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
+2
View File
@@ -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
+26 -2
View File
@@ -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",
+26 -2
View File
@@ -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 已验证",