fix(plugins): install GitHub marketplace plugins through the instance

Desktop install was failing with Failed to fetch because the renderer
fetched GitHub metadata directly. Resolve live official releases and
download Release assets via the current EdgeEver instance, so daily
plugin updates still work without a client-side GitHub API round trip.
This commit is contained in:
tianma-if committed 2026-09-09 19:26:11 +08:00
1 parent a853c92350
commit ad9edb1ff8
9 files changed
+527 -28

No files matched your search

@@ -1,5 +1,10 @@
import { describe, expect, test } from "bun:test";
import { downloadGithubReleaseAsset, downloadGithubReleaseAssetByTag } from "./plugin-distribution-routes.ts";
import {
downloadGithubReleaseAsset,
downloadGithubReleaseAssetByTag,
readGithubReleaseByTag,
readGithubRepositoryManifestText,
} from "./plugin-distribution-routes.ts";
describe("GitHub plugin release asset proxy", () => {
test("downloads a supported release asset on the server", async () => {
@@ -65,4 +70,46 @@ describe("GitHub plugin release asset proxy", () => {
request: async () => new Response("should not be fetched"),
})).rejects.toThrow("Invalid GitHub release coordinates");
});
test("reads the raw repository manifest for live official plugin resolution", async () => {
const text = await readGithubRepositoryManifestText({
owner: "example",
repository: "edgeever-plugin",
request: async () => new Response("{\"version\":\"0.5.4\"}", { headers: { "content-length": "20" } }),
});
expect(text).toBe("{\"version\":\"0.5.4\"}");
});
test("reads a GitHub release by tag without exposing unrelated fields", async () => {
const release = await readGithubReleaseByTag({
owner: "example",
repository: "edgeever-plugin",
releaseTag: "v0.5.4",
request: async () => Response.json({
tag_name: "v0.5.4",
draft: false,
extra: "omit",
assets: [{
id: 9,
name: "main.js",
size: 12,
url: "https://api.github.com/assets/9",
browser_download_url: "https://github.com/download/main.js",
digest: "sha256:abc",
}],
}),
});
expect(release).toEqual({
tag_name: "v0.5.4",
draft: false,
assets: [{
id: 9,
name: "main.js",
size: 12,
url: "https://api.github.com/assets/9",
browser_download_url: "https://github.com/download/main.js",
digest: "sha256:abc",
}],
});
});
});
+133 -1
View File
@@ -1,6 +1,6 @@
import type { Hono } from "hono";
import type { AppEnv } from "./api-context";
import { apiError, badRequest } from "./http-errors";
import { apiError, badRequest, notFound } from "./http-errors";
const GITHUB_API_VERSION = "2022-11-28";
const OWNER_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,38})$/i;
@@ -55,6 +55,105 @@ export const downloadGithubReleaseAsset = async ({
return readBoundedAsset(response, assetName);
};
class GithubUpstreamError extends Error {
status: number;
constructor(message: string, status: number) {
super(message);
this.name = "GithubUpstreamError";
this.status = status;
}
}
export const readGithubRepositoryManifestText = async ({
owner,
repository,
request = fetch,
}: {
owner: string;
repository: string;
request?: typeof fetch;
}) => {
if (!hasValidRepositoryCoordinates(owner, repository)) throw new Error("Invalid GitHub repository coordinates.");
const response = await request(`https://api.github.com/repos/${owner}/${repository}/contents/manifest.json`, {
headers: {
Accept: "application/vnd.github.raw+json",
"User-Agent": "EdgeEver",
"X-GitHub-Api-Version": GITHUB_API_VERSION,
},
});
if (response.status === 404) throw new GithubUpstreamError("Repository manifest was not found.", 404);
if (!response.ok) throw new Error(`Repository manifest request failed with HTTP ${response.status}.`);
const text = await response.text();
if (new TextEncoder().encode(text).byteLength > ASSET_LIMITS["manifest.json"]) {
throw new Error("manifest.json exceeds the allowed package size.");
}
return text;
};
export const readGithubReleaseByTag = async ({
owner,
repository,
releaseTag,
request = fetch,
}: {
owner: string;
repository: string;
releaseTag: string;
request?: typeof fetch;
}) => {
if (!hasValidRepositoryCoordinates(owner, repository) || !RELEASE_TAG_PATTERN.test(releaseTag)) {
throw new Error("Invalid GitHub release coordinates.");
}
const response = await request(
`https://api.github.com/repos/${owner}/${repository}/releases/tags/${encodeURIComponent(releaseTag)}`,
{
headers: {
Accept: "application/vnd.github+json",
"User-Agent": "EdgeEver",
"X-GitHub-Api-Version": GITHUB_API_VERSION,
},
},
);
if (response.status === 404) throw new GithubUpstreamError("GitHub release was not found.", 404);
if (!response.ok) throw new Error(`GitHub release request failed with HTTP ${response.status}.`);
const release = await response.json() as {
tag_name?: unknown;
draft?: unknown;
assets?: unknown;
};
if (typeof release.tag_name !== "string" || !Array.isArray(release.assets)) {
throw new Error("GitHub release response is invalid.");
}
return {
tag_name: release.tag_name,
draft: release.draft === true,
assets: release.assets.flatMap((asset) => {
if (!asset || typeof asset !== "object") return [];
const record = asset as Record<string, unknown>;
if (typeof record.id !== "number" || typeof record.name !== "string" || typeof record.size !== "number") return [];
if (typeof record.url !== "string" || typeof record.browser_download_url !== "string") return [];
return [{
id: record.id,
name: record.name,
size: record.size,
url: record.url,
browser_download_url: record.browser_download_url,
...(typeof record.digest === "string" ? { digest: record.digest } : {}),
}];
}),
};
};
const githubUpstreamError = (context: Parameters<typeof apiError>[0], error: unknown) => {
if (error instanceof GithubUpstreamError && error.status === 404) return notFound(context, error.message);
return apiError(
context,
"github_metadata_request_failed",
error instanceof Error ? error.message : "GitHub plugin metadata request failed.",
502,
);
};
export const downloadGithubReleaseAssetByTag = async ({
owner,
repository,
@@ -80,6 +179,39 @@ export const downloadGithubReleaseAssetByTag = async ({
};
export const registerPluginDistributionRoutes = (app: Hono<AppEnv>) => {
app.get("/api/v1/plugins/github/:owner/:repository/manifest", async (context) => {
const owner = context.req.param("owner");
const repository = context.req.param("repository");
if (!hasValidRepositoryCoordinates(owner, repository)) {
return badRequest(context, "Invalid GitHub repository coordinates.");
}
try {
const text = await readGithubRepositoryManifestText({ owner, repository });
return context.text(text, 200, {
"Cache-Control": "private, max-age=60",
"Content-Type": "application/json; charset=utf-8",
});
} catch (error) {
return githubUpstreamError(context, error);
}
});
app.get("/api/v1/plugins/github/:owner/:repository/releases/tags/:releaseTag", async (context) => {
const owner = context.req.param("owner");
const repository = context.req.param("repository");
const releaseTag = context.req.param("releaseTag");
if (!hasValidRepositoryCoordinates(owner, repository) || !RELEASE_TAG_PATTERN.test(releaseTag)) {
return badRequest(context, "Invalid GitHub release coordinates.");
}
try {
const release = await readGithubReleaseByTag({ owner, repository, releaseTag });
context.header("Cache-Control", "private, max-age=60");
return context.json(release);
} catch (error) {
return githubUpstreamError(context, error);
}
});
app.get("/api/v1/plugins/github/:owner/:repository/releases/:releaseTag/assets/:assetName", async (context) => {
const owner = context.req.param("owner");
const repository = context.req.param("repository");
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
import { downloadGithubExtension, parseGithubRepositoryUrl } from "./github-plugin-distribution.ts";
import { downloadGithubExtension, downloadPinnedGithubExtension, parseGithubRepositoryUrl } from "./github-plugin-distribution.ts";
const manifest = {
type: "plugin",
@@ -101,4 +101,80 @@ describe("GitHub plugin distribution", () => {
await expect(downloadGithubExtension("https://github.com/example/edgeever-plugin", request, downloadAsset))
.rejects.toThrow("does not match the repository manifest");
});
test("explains a renderer network failure instead of showing Failed to fetch", async () => {
const request = async () => {
throw new TypeError("Failed to fetch");
};
await expect(downloadGithubExtension("https://github.com/example/edgeever-plugin", request, async () => new ArrayBuffer(0)))
.rejects.toThrow("Could not read this GitHub plugin from this device or your EdgeEver instance");
});
test("installs a marketplace GitHub plugin from the pinned release without calling GitHub's REST API", async () => {
const calls = [];
const assets = {
"manifest.json": new TextEncoder().encode(JSON.stringify(manifest)).buffer,
"main.js": new TextEncoder().encode("export default { activate() {} };").buffer,
"styles.css": new TextEncoder().encode(".ai-rss {}").buffer,
};
const downloadAsset = async (_coordinates, releaseTag, asset) => {
calls.push([releaseTag, asset.name]);
const buffer = assets[asset.name];
if (!buffer) throw new Error(`GitHub asset ${asset.name} failed with HTTP 404.`);
return buffer;
};
const downloaded = await downloadPinnedGithubExtension("https://github.com/example/edgeever-plugin", "1.2.3", {
downloadAssetBytes: downloadAsset,
requireStyles: true,
});
expect(downloaded.releaseTag).toBe("1.2.3");
expect(downloaded.manifest.id).toBe("org.edgeever.github-test");
expect(downloaded.pluginPackage?.mainJs).toContain("activate");
expect(downloaded.checksums.stylesCss).toHaveLength(64);
expect(calls[0]).toEqual(["1.2.3", "manifest.json"]);
expect(calls).toContainEqual(["1.2.3", "main.js"]);
expect(calls).toContainEqual(["1.2.3", "styles.css"]);
});
test("falls back to a v-prefixed marketplace release tag when the unprefixed tag is missing", async () => {
const calls = [];
const assets = {
"manifest.json": new TextEncoder().encode(JSON.stringify(manifest)).buffer,
"main.js": new TextEncoder().encode("export default { activate() {} };").buffer,
};
const downloadAsset = async (_coordinates, releaseTag, asset) => {
calls.push([releaseTag, asset.name]);
if (releaseTag === "1.2.3") throw new Error(`GitHub asset ${asset.name} failed with HTTP 404.`);
const buffer = assets[asset.name];
if (!buffer) throw new Error(`GitHub asset ${asset.name} failed with HTTP 404.`);
return buffer;
};
const downloaded = await downloadPinnedGithubExtension("https://github.com/example/edgeever-plugin", "1.2.3", {
downloadAssetBytes: downloadAsset,
});
expect(downloaded.releaseTag).toBe("v1.2.3");
expect(calls[0]).toEqual(["1.2.3", "manifest.json"]);
expect(calls).toContainEqual(["v1.2.3", "main.js"]);
});
test("installs the live resolved marketplace version, not a frozen bundled registry pin", async () => {
const liveManifest = { ...manifest, version: "0.5.4" };
const downloadAsset = async (_coordinates, releaseTag, asset) => {
expect(releaseTag).toBe("0.5.4");
return new TextEncoder().encode(
asset.name === "manifest.json" ? JSON.stringify(liveManifest) : "export default { activate() {} };",
).buffer;
};
const downloaded = await downloadPinnedGithubExtension("https://github.com/example/edgeever-plugin", "0.5.4", {
downloadAssetBytes: downloadAsset,
});
expect(downloaded.manifest.version).toBe("0.5.4");
});
});
@@ -1,10 +1,13 @@
import { parseExtensionManifest, type ExtensionManifest, type PluginManifest } from "@edgeever/plugin-api";
import type { CachedPluginPackage } from "@/lib/plugins/plugin-package-store";
import { api } from "@/lib/api";
import { api, ApiRequestError } from "@/lib/api";
const GITHUB_API_VERSION = "2022-11-28";
const GITHUB_API_TIMEOUT_MS = 12_000;
const MAX_MAIN_JS_BYTES = 5 * 1024 * 1024;
const MAX_STYLES_CSS_BYTES = 1024 * 1024;
const GITHUB_UNREACHABLE_MESSAGE =
"Could not read this GitHub plugin from this device or your EdgeEver instance.";
export interface GithubRepositoryCoordinates {
owner: string;
@@ -65,15 +68,78 @@ export const sha256Hex = async (value: string | ArrayBuffer) => {
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
};
const isGithubUnreachableError = (error: unknown) => {
if (error instanceof TypeError) return true;
return error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError");
};
const isMissingGithubAssetError = (error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
return /HTTP 404/i.test(message);
};
const fetchGithubMetadataThroughInstance = async (url: string): Promise<Response> => {
const parsed = new URL(url);
if (parsed.protocol !== "https:" || parsed.hostname.toLocaleLowerCase() !== "api.github.com") {
throw new Error("Unsupported GitHub API host.");
}
const parts = parsed.pathname.split("/").filter(Boolean);
if (parts[0] !== "repos" || parts.length < 5) throw new Error("Unsupported GitHub API path.");
const owner = parts[1];
const repository = parts[2];
try {
if (parts[3] === "contents" && parts[4] === "manifest.json") {
const text = await api.getGithubPluginRepositoryManifest(owner, repository);
return new Response(text, { status: 200, headers: { "content-type": "application/json" } });
}
if (parts[3] === "releases" && parts[4] === "tags" && parts[5]) {
const release = await api.getGithubPluginRelease(owner, repository, decodeURIComponent(parts[5]));
if (!release) return new Response(null, { status: 404 });
return Response.json(release);
}
} catch (error) {
if (error instanceof ApiRequestError && error.status === 404) return new Response(null, { status: 404 });
throw error;
}
throw new Error("Unsupported GitHub API path.");
};
const readGithubApi = async (
request: typeof fetch,
url: string,
headers: Record<string, string>,
) => {
try {
return await request(url, {
cache: "no-store",
credentials: "omit",
headers,
signal: AbortSignal.timeout(GITHUB_API_TIMEOUT_MS),
});
} catch (error) {
if (!isGithubUnreachableError(error)) throw error;
try {
return await fetchGithubMetadataThroughInstance(url);
} catch {
throw new Error(GITHUB_UNREACHABLE_MESSAGE);
}
}
};
const namedReleaseAsset = (name: string): GithubReleaseAsset => ({
id: 0,
name,
size: 0,
url: "",
browser_download_url: "",
});
const findRelease = async (request: typeof fetch, coordinates: GithubRepositoryCoordinates, version: string) => {
for (const tag of [version, `v${version}`]) {
const response = await request(
const response = await readGithubApi(
request,
`https://api.github.com/repos/${coordinates.owner}/${coordinates.repository}/releases/tags/${encodeURIComponent(tag)}`,
{
cache: "no-store",
credentials: "omit",
headers: { Accept: "application/vnd.github+json", "X-GitHub-Api-Version": GITHUB_API_VERSION },
}
{ Accept: "application/vnd.github+json", "X-GitHub-Api-Version": GITHUB_API_VERSION },
);
if (response.status === 404) continue;
if (!response.ok) throw new Error(`GitHub release request failed with HTTP ${response.status}.`);
@@ -96,13 +162,21 @@ export type GithubAssetDownloader = (
asset: GithubReleaseAsset,
) => Promise<ArrayBuffer>;
const downloadGithubAssetThroughApi: GithubAssetDownloader = (coordinates, releaseTag, asset) =>
api.downloadGithubPluginAsset(
coordinates.owner,
coordinates.repository,
releaseTag,
asset.name as "manifest.json" | "main.js" | "styles.css",
);
const downloadGithubAssetThroughApi: GithubAssetDownloader = async (coordinates, releaseTag, asset) => {
try {
return await api.downloadGithubPluginAsset(
coordinates.owner,
coordinates.repository,
releaseTag,
asset.name as "manifest.json" | "main.js" | "styles.css",
);
} catch (error) {
if (isGithubUnreachableError(error)) {
throw new Error("Could not download the plugin package from your EdgeEver instance.");
}
throw error;
}
};
const downloadAsset = async (
coordinates: GithubRepositoryCoordinates,
@@ -155,11 +229,11 @@ export const loadGithubRepositoryManifest = async (
const coordinates = parseGithubRepositoryUrl(input);
if (!coordinates) throw new Error("Enter a public GitHub repository URL such as https://github.com/owner/repository.");
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 },
});
const manifestResponse = await readGithubApi(
request,
manifestUrl,
{ 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 manifestText = await manifestResponse.text();
return {
@@ -229,3 +303,116 @@ export const downloadGithubExtension = async (
},
};
};
const downloadOptionalStyles = async (
coordinates: GithubRepositoryCoordinates,
releaseTag: string,
downloadAssetBytes: GithubAssetDownloader,
requireStyles: boolean,
) => {
try {
return await downloadAsset(
coordinates,
releaseTag,
namedReleaseAsset("styles.css"),
MAX_STYLES_CSS_BYTES,
downloadAssetBytes,
);
} catch (error) {
if (requireStyles || !isMissingGithubAssetError(error)) throw error;
return null;
}
};
const downloadPinnedGithubRelease = async (
coordinates: GithubRepositoryCoordinates,
releaseTag: string,
expectedVersion: string,
downloadAssetBytes: GithubAssetDownloader,
requireStyles: boolean,
): Promise<GithubDownloadedExtension> => {
const downloadedManifest = await downloadAsset(
coordinates,
releaseTag,
namedReleaseAsset("manifest.json"),
256 * 1024,
downloadAssetBytes,
);
const releaseManifest = parseExtensionManifest(
JSON.parse(new TextDecoder().decode(downloadedManifest.buffer)) as unknown,
);
if (releaseManifest.version !== expectedVersion) {
throw new Error("Downloaded version does not match the marketplace verified version.");
}
const manifestUrl = `https://github.com/${coordinates.owner}/${coordinates.repository}/releases/download/${releaseTag}/manifest.json`;
if (releaseManifest.type === "theme") {
return {
manifest: releaseManifest,
manifestUrl,
repositoryUrl: coordinates.repositoryUrl,
releaseTag,
pluginPackage: null,
checksums: { manifestJson: downloadedManifest.checksum },
};
}
assertBundledEntry(releaseManifest);
const [downloadedMain, downloadedStyles] = await Promise.all([
downloadAsset(coordinates, releaseTag, namedReleaseAsset("main.js"), MAX_MAIN_JS_BYTES, downloadAssetBytes),
downloadOptionalStyles(coordinates, releaseTag, downloadAssetBytes, requireStyles),
]);
return {
manifest: releaseManifest,
manifestUrl,
repositoryUrl: coordinates.repositoryUrl,
releaseTag,
pluginPackage: {
pluginId: releaseManifest.id,
version: releaseManifest.version,
mainJs: new TextDecoder().decode(downloadedMain.buffer),
stylesCss: downloadedStyles ? new TextDecoder().decode(downloadedStyles.buffer) : null,
checksums: {
manifestJson: downloadedManifest.checksum,
mainJs: downloadedMain.checksum,
...(downloadedStyles ? { stylesCss: downloadedStyles.checksum } : {}),
},
cachedAt: new Date().toISOString(),
},
checksums: {
manifestJson: downloadedManifest.checksum,
mainJs: downloadedMain.checksum,
...(downloadedStyles ? { stylesCss: downloadedStyles.checksum } : {}),
},
};
};
export const downloadPinnedGithubExtension = async (
input: string,
version: string,
options: {
downloadAssetBytes?: GithubAssetDownloader;
requireStyles?: boolean;
} = {},
): 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 downloadAssetBytes = options.downloadAssetBytes ?? downloadGithubAssetThroughApi;
const requireStyles = Boolean(options.requireStyles);
let lastError: Error | null = null;
for (const releaseTag of [version, `v${version}`]) {
try {
return await downloadPinnedGithubRelease(
coordinates,
releaseTag,
version,
downloadAssetBytes,
requireStyles,
);
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
if (!isMissingGithubAssetError(error)) throw lastError;
}
}
throw lastError ?? new Error(`GitHub Release ${version} or v${version} was not found.`);
};
+7 -2
View File
@@ -36,7 +36,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, extensionManifestsEqual, parseGithubRepositoryUrl, sha256Hex } from "@/lib/plugins/github-plugin-distribution";
import { downloadGithubExtension, downloadPinnedGithubExtension, extensionManifestsEqual, parseGithubRepositoryUrl, sha256Hex } from "@/lib/plugins/github-plugin-distribution";
import { subscribeRepositoryMutations, type RepositoryMutationEvent } from "@/lib/repository-events";
const INSTALLED_EXTENSIONS_STORAGE_KEY = "edgeever.extensions.installed.v1";
@@ -497,7 +497,12 @@ export class EdgeEverPluginHost {
}
async installFromGithubRepository(input: string, marketplaceEntry?: MarketplaceEntry, confirmedManifest?: ExtensionManifest) {
const downloaded = await downloadGithubExtension(input);
// Official marketplace entries carry the live GitHub version after marketplace resolution.
const downloaded = marketplaceEntry
? await downloadPinnedGithubExtension(input, marketplaceEntry.verification.version, {
requireStyles: Boolean(marketplaceEntry.verification.checksums?.stylesCss),
})
: await downloadGithubExtension(input);
assertConfirmedManifest(confirmedManifest, downloaded.manifest);
if (marketplaceEntry) this.assertMarketplaceDownload(marketplaceEntry, downloaded.manifest, downloaded.checksums);
return this.replaceInstalledExtension(downloaded.manifest, downloaded.manifestUrl, {
+2 -2
View File
@@ -43,9 +43,9 @@ main.js
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.
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. Marketplace GitHub metadata and Release assets are fetched through the current EdgeEver instance, so the desktop or browser client does not need direct access to `api.github.com`. `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. Marketplace installs whose Registry entry declares `"publisher": "edgeever"` are official EdgeEver extensions and update automatically to the latest checksum-pinned Registry version. Community Marketplace extensions and extensions installed directly from GitHub or a Manifest URL are never updated silently: the user must click Update and confirm. If a manually confirmed version changes declared capabilities or legacy network-host metadata, the confirmation lists those changes for review. 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.
EdgeEver checks for updates when the Plugin Marketplace opens, when the window regains focus, and every 30 minutes. Marketplace installs whose Registry entry declares `"publisher": "edgeever"` are official EdgeEver extensions. The bundled Registry version is the verified baseline; EdgeEver then resolves the live GitHub default-branch Release through the instance and auto-updates to that newer checksum-pinned version. Community Marketplace extensions and extensions installed directly from GitHub or a Manifest URL are never updated silently: the user must click Update and confirm. If a manually confirmed version changes declared capabilities or legacy network-host metadata, the confirmation lists those changes for review. For GitHub distribution, the Release `manifest.json` must exactly match the default-branch manifest used for the update prompt or installation is rejected.
Updates use a rollback-capable switch. Old and new package versions are cached separately. If the new version cannot activate, EdgeEver restores the previous manifest, enabled state, and package instead of leaving the plugin broken or disabled.
+2 -2
View File
@@ -43,9 +43,9 @@ main.js
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。
GitHub 插件的 `entry` 固定为 `./main.js`,`main.js` 必须是无需相对模块导入的单文件 Bundle。EdgeEver 会读取默认分支 Manifest、查找相同版本的 Release、并行下载资产、验证 GitHub 提供的 SHA-256 Digest(如果存在),然后把验证后的包缓存到当前设备的 IndexedDB。插件市场的 GitHub 元数据和 Release 资产都由当前 EdgeEver 实例代理获取,因此桌面端或浏览器不必直接访问 `api.github.com`。`main.js` 上限为 5 MB,`styles.css` 上限为 1 MB。
EdgeEver 会在插件市场页面打开、窗口重新获得焦点及每 30 分钟检查一次更新。Registry 条目声明 `"publisher": "edgeever"` 的市场安装属于 EdgeEver 官方扩展,会自动更新到 Registry 中通过校验和固定的最新版本。社区市场扩展以及从 GitHub 或 Manifest 地址直接安装的扩展绝不会静默更新,用户必须点击「更新」并确认;如果手动确认的新版改变能力声明或旧版网络域名元数据,确认框会列出这些变化供用户查看。GitHub 分发的 Release `manifest.json` 必须与默认分支中用于提示更新的 Manifest 完全一致,否则安装会被拒绝。市场安装只跟随 Registry 中已经验证的新版本。
EdgeEver 会在插件市场页面打开、窗口重新获得焦点及每 30 分钟检查一次更新。Registry 条目声明 `"publisher": "edgeever"` 的市场安装属于 EdgeEver 官方扩展。应用内 Registry 版本是已校验的基线;EdgeEver 会通过实例解析 GitHub 默认分支上的最新 Release,并自动更新到该校验和固定版本。社区市场扩展以及从 GitHub 或 Manifest 地址直接安装的扩展绝不会静默更新,用户必须点击「更新」并确认;如果手动确认的新版改变能力声明或旧版网络域名元数据,确认框会列出这些变化供用户查看。GitHub 分发的 Release `manifest.json` 必须与默认分支中用于提示更新的 Manifest 完全一致,否则安装会被拒绝。
升级采用可回滚切换:新旧版本的包会分别缓存;如果新版无法激活,EdgeEver 会恢复原 Manifest、原启用状态和上一版本代码,而不是留下一个被破坏或被停用的插件。
+26
View File
@@ -171,6 +171,32 @@ describe("EdgeEver client HTTP contract", () => {
expect(Array.from(new Uint8Array(buffer))).toEqual([1, 2, 3]);
});
test("reads GitHub plugin metadata through the instance proxy", async () => {
const calls = [];
const client = createEdgeEverClient({
fetch: async (input) => {
const url = String(input);
calls.push(url);
if (url.endsWith("/manifest")) return new Response("{\"id\":\"plugin\"}");
if (url.includes("/releases/tags/missing")) return new Response(null, { status: 404 });
return Response.json({ tag_name: "v1.2.3", draft: false, assets: [] });
},
});
expect(await client.getGithubPluginRepositoryManifest("example-owner", "example-plugin")).toBe("{\"id\":\"plugin\"}");
expect(await client.getGithubPluginRelease("example-owner", "example-plugin", "missing")).toBeNull();
expect(await client.getGithubPluginRelease("example-owner", "example-plugin", "v1.2.3")).toEqual({
tag_name: "v1.2.3",
draft: false,
assets: [],
});
expect(calls).toEqual([
"/api/v1/plugins/github/example-owner/example-plugin/manifest",
"/api/v1/plugins/github/example-owner/example-plugin/releases/tags/missing",
"/api/v1/plugins/github/example-owner/example-plugin/releases/tags/v1.2.3",
]);
});
test("streams restored resources through bounded multipart requests", async () => {
const calls = [];
const client = createEdgeEverClient({
+26
View File
@@ -1238,6 +1238,32 @@ export const createEdgeEverClient = (options: EdgeEverClientOptions = {}) => {
`/api/v1/plugins/github/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/releases/${encodeURIComponent(releaseTag)}/assets/${encodeURIComponent(assetName)}`,
),
getGithubPluginRepositoryManifest: async (owner: string, repository: string) => {
const path = `/api/v1/plugins/github/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/manifest`;
const { context, response } = await send(path, undefined, { setJsonContentType: false });
if (!response.ok) await throwRequestError(context, response, "GitHub plugin manifest request failed");
return response.text();
},
getGithubPluginRelease: async (owner: string, repository: string, releaseTag: string) => {
const path = `/api/v1/plugins/github/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/releases/tags/${encodeURIComponent(releaseTag)}`;
const { context, response } = await send(path, undefined, { setJsonContentType: false });
if (response.status === 404) return null;
if (!response.ok) await throwRequestError(context, response, "GitHub plugin release request failed");
return response.json() as Promise<{
tag_name: string;
draft: boolean;
assets: Array<{
id: number;
name: string;
size: number;
url: string;
browser_download_url: string;
digest?: string;
}>;
}>;
},
uploadMemoResource: (memoId: string, file: Blob | FormData) => {
// Small files do not benefit from an upload session's three round trips.
// Keep large attachments on the bounded-memory, resumable path.