Files
edgeever/apps/web/src/lib/plugins/plugin-updates.ts
T
tianma-if be7bbe25e6 feat(plugins): append a signed community catalog beside official plugins
Official entries stay on the bundled registry. The instance returns a community file only when EDGE_EVER_COMMUNITY_REGISTRY_URL is set, and submission opens from its own marketplace entry.
2026-10-06 18:38:37 +08:00

159 lines
7.2 KiB
TypeScript

import { parseExtensionManifest, type ExtensionManifest, type MarketplaceEntry, type PluginPermission } from "@edgeever/plugin-api";
import type { InstalledExtension } from "@/lib/plugins/plugin-host";
import { readRevokedPluginIds } from "@/lib/plugins/community-registry-store";
import { loadGithubInstallableManifest } 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>;
}
type PluginUpdateHost = Pick<
import("@/lib/plugins/plugin-host").EdgeEverPluginHost,
"getSnapshot" | "installMarketplaceEntry" | "installFromGithubRepository" | "installFromManifestUrl"
>;
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 loadGithubInstallableManifest(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),
revokedIds?: ReadonlySet<string>,
): Promise<PluginUpdateInfo | null> => {
if (revokedIds?.has(extension.manifest.id)) return 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 loadGithubInstallableManifest(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),
options?: { revokedIds?: ReadonlySet<string> },
): Promise<PluginUpdateCheckResult> => {
const settled = await Promise.allSettled(
extensions.map((extension) => checkInstalledExtensionUpdate(extension, marketplaceEntries, request, options?.revokedIds))
);
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 };
};
export const applyPluginUpdate = async (host: PluginUpdateHost, update: PluginUpdateInfo) => {
const extension = host.getSnapshot().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);
}
};
export interface OfficialPluginUpdateResult {
updated: PluginUpdateInfo[];
errors: Record<string, string>;
}
export const updateOfficialMarketplacePlugins = async (
host: PluginUpdateHost,
marketplaceEntries: MarketplaceEntry[],
request: typeof fetch = window.fetch.bind(window),
options?: { revokedIds?: ReadonlySet<string> },
): Promise<OfficialPluginUpdateResult> => {
const revokedIds = options?.revokedIds ?? await readRevokedPluginIds();
const checked = await checkPluginUpdates(host.getSnapshot().extensions, marketplaceEntries, request, { revokedIds });
const officialUpdates = checked.updates.filter((update) => {
if (revokedIds.has(update.pluginId)) return false;
if (update.marketplaceEntry?.publisher !== "edgeever") return false;
if (!update.marketplaceEntry.verification.checksums?.manifestJson) return false;
return update.latestManifest.type !== "plugin" || Boolean(update.marketplaceEntry.verification.checksums.mainJs);
});
const updated: PluginUpdateInfo[] = [];
const errors = { ...checked.errors };
for (const update of officialUpdates) {
try {
await applyPluginUpdate(host, update);
updated.push(update);
} catch (error) {
errors[update.pluginId] = error instanceof Error ? error.message : String(error);
}
}
return { updated, errors };
};