feat: add plugin updates and details
This commit is contained in:
1 parent
c657e383a0
commit
dc7626843a
24 files changed
+998
-170
No files matched your search
@@ -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,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": []
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 已验证",
|
||||
|
||||
Reference in new issue
Block a user