823 lines
26 KiB
TypeScript
823 lines
26 KiB
TypeScript
import { getCorePlugins } from "@core/plugins";
|
|
import { readFile, removeFile, writeFile } from "@lib/api/native/fs";
|
|
import {
|
|
awaitStorage,
|
|
createStorage,
|
|
getPreloadedStorage,
|
|
preloadStorageIfExists,
|
|
purgeStorage,
|
|
updateStorage,
|
|
} from "@lib/api/storage";
|
|
import { safeFetch } from "@lib/utils";
|
|
import { OFFICIAL_PLUGINS_REPO_URL } from "@lib/utils/constants";
|
|
import { semver } from "@metro/common";
|
|
import { updaterSettings } from "@lib/api/settings";
|
|
|
|
import { createBunnyPluginApi } from "./api";
|
|
import * as t from "./types";
|
|
|
|
type PluginInstantiator = (
|
|
bunny: t.BunnyPluginObject,
|
|
definePlugin?: (p: t.PluginInstance) => t.PluginInstanceInternal,
|
|
) => t.PluginInstanceInternal;
|
|
|
|
// Core plugins instances are stored both in this and pluginInstance
|
|
// This exists because pluginInstances only stores running plugins while this one
|
|
// stores the always existing core plugins instances (which can't be destroyed)
|
|
export const corePluginInstances = new Map<string, t.PluginInstanceInternal>();
|
|
|
|
export const registeredPlugins = new Map<string, t.BunnyPluginManifest>();
|
|
export const pluginInstances = new Map<string, t.PluginInstanceInternal>();
|
|
export const apiObjects = new Map<
|
|
string,
|
|
ReturnType<typeof createBunnyPluginApi>
|
|
>();
|
|
|
|
/**
|
|
* Register core plugins into the runtime registries so they appear in the UI
|
|
* as soon as this module is imported. This function is intentionally synchronous
|
|
* and idempotent so it is safe to call at module-load time or from initialization
|
|
* code.
|
|
*/
|
|
export function registerCorePlugins() {
|
|
try {
|
|
const core = getCorePlugins();
|
|
for (const id in core) {
|
|
try {
|
|
const { default: instance, preenabled } = core[id];
|
|
|
|
// Ensure manifest is registered for UI listing
|
|
registeredPlugins.set(id, instance.manifest);
|
|
|
|
// Keep a reference to the core plugin instance so isCorePlugin checks work
|
|
corePluginInstances.set(id, instance);
|
|
|
|
// Ensure pluginSettings has an entry for the core plugin so the UI can
|
|
// reflect enabled/disabled state. Do not overwrite existing user settings.
|
|
try {
|
|
// `pluginSettings` is a storage wrapper; ensure we only set a default if absent.
|
|
if (pluginSettings[id] == null) {
|
|
pluginSettings[id] = { enabled: preenabled ?? true };
|
|
}
|
|
} catch (e) {
|
|
// Best-effort: if storage isn't available yet, ignore and continue.
|
|
console.error(
|
|
"Failed to set default pluginSettings for core plugin",
|
|
id,
|
|
e,
|
|
);
|
|
}
|
|
} catch (e) {
|
|
console.error("Failed to register core plugin", id, e);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error("registerCorePlugins: unexpected error", e);
|
|
}
|
|
}
|
|
|
|
// Eager core plugin registration removed to avoid circular require / timing issues.
|
|
// Call `registerCorePlugins()` explicitly from initialization code when appropriate,
|
|
// for example during a safe deferred startup step.
|
|
|
|
export const pluginRepositories = createStorage<t.PluginRepoStorage>(
|
|
"plugins/repositories.json",
|
|
);
|
|
export const pluginSettings = createStorage<t.PluginSettingsStorage>(
|
|
"plugins/settings.json",
|
|
);
|
|
|
|
const _fetch = (repoUrl: string, path: string) => {
|
|
try {
|
|
const u = new URL(path, repoUrl);
|
|
// Append cache-busting query parameter so manual refresh triggers network requests
|
|
u.searchParams.set("_", String(Date.now()));
|
|
return safeFetch(u, { cache: "no-store" });
|
|
} catch (e) {
|
|
// Fallback to original behavior if URL construction fails for any reason
|
|
return safeFetch(new URL(path, repoUrl), { cache: "no-store" });
|
|
}
|
|
};
|
|
const fetchJS = (repoUrl: string, path: string) =>
|
|
_fetch(repoUrl, path).then((r) => r.text());
|
|
const fetchJSON = (repoUrl: string, path: string) =>
|
|
_fetch(repoUrl, path).then((r) => r.json());
|
|
|
|
function assert<T>(
|
|
condition: T,
|
|
id: string,
|
|
attempt: string,
|
|
): asserts condition {
|
|
if (!condition) throw new Error(`[${id}] Attempted to ${attempt}`);
|
|
}
|
|
|
|
/**
|
|
* Checks if a version is newer than the other. However, this comes with an additional logic,
|
|
* where if the version are equal, one with prerelease "tag" will be considered "newer"
|
|
* @internal
|
|
* @returns Whether the version is newer
|
|
*/
|
|
export function isGreaterVersion(v1: string, v2: string) {
|
|
if (semver.gt(v1, v2)) return true;
|
|
const coerced = semver.coerce(v1);
|
|
if (coerced == null) return false;
|
|
return semver.prerelease(v1)?.includes("dev") && semver.eq(coerced, v2);
|
|
}
|
|
|
|
function isExternalPlugin(
|
|
manifest: t.BunnyPluginManifest,
|
|
): manifest is t.BunnyPluginManifestInternal {
|
|
return "parentRepository" in manifest;
|
|
}
|
|
|
|
export function isCorePlugin(id: string) {
|
|
return corePluginInstances.has(id);
|
|
}
|
|
|
|
export function getPluginSettingsComponent(
|
|
id: string,
|
|
): React.ComponentType<any> | null {
|
|
const instance = pluginInstances.get(id);
|
|
if (!instance) return null;
|
|
|
|
if (instance.SettingsComponent) return instance.SettingsComponent;
|
|
return null;
|
|
}
|
|
|
|
export function isPluginInstalled(id: string) {
|
|
return pluginSettings[id] != null;
|
|
}
|
|
|
|
export function isPluginEnabled(id: string) {
|
|
return Boolean(pluginSettings[id]?.enabled);
|
|
}
|
|
|
|
/**
|
|
* Fetch and write the plugin to thier respective storage. This does not compare the version nor execute the plugin
|
|
* @param repoUrl URL to the plugin repository
|
|
* @param id The ID of the plugin
|
|
* @returns The newly fetched plugin manifest
|
|
*/
|
|
export async function updateAndWritePlugin(
|
|
repoUrl: string,
|
|
id: string,
|
|
fetchScript: boolean,
|
|
) {
|
|
// Try multiple manifest/script paths and provide robust logging so we can trace
|
|
// exactly which network requests are performed. Primary path is `builds/...`,
|
|
// fallback paths include `id/manifest.json` and `id/index.js` (some repos expose those).
|
|
const manifestCandidates = [
|
|
`builds/${id}/manifest.json`,
|
|
`${id}/manifest.json`,
|
|
];
|
|
|
|
let manifest: t.BunnyPluginManifestInternal | undefined;
|
|
let lastManifestErr: any = null;
|
|
|
|
for (const candidate of manifestCandidates) {
|
|
const url = new URL(candidate, repoUrl).toString();
|
|
try {
|
|
manifest = await fetchJSON(repoUrl, candidate);
|
|
break;
|
|
} catch (e) {
|
|
lastManifestErr = e;
|
|
}
|
|
}
|
|
|
|
if (!manifest) {
|
|
throw lastManifestErr ?? new Error(`Failed to fetch manifest for ${id}`);
|
|
}
|
|
|
|
// @ts-expect-error - Setting a readonly property
|
|
manifest.parentRepository = repoUrl;
|
|
|
|
if (fetchScript) {
|
|
// @ts-expect-error - Setting a readonly property
|
|
manifest.jsPath = `plugins/scripts/${id}.js`;
|
|
|
|
const scriptCandidates = [`builds/${id}/index.js`, `${id}/index.js`];
|
|
let js: string | undefined;
|
|
let lastScriptErr: any = null;
|
|
|
|
for (const candidate of scriptCandidates) {
|
|
const url = new URL(candidate, repoUrl).toString();
|
|
try {
|
|
js = await fetchJS(repoUrl, candidate);
|
|
break;
|
|
} catch (e) {
|
|
lastScriptErr = e;
|
|
}
|
|
}
|
|
|
|
if (!js) {
|
|
throw lastScriptErr ?? new Error(`Failed to fetch script for ${id}`);
|
|
}
|
|
|
|
try {
|
|
await writeFile(manifest.jsPath, js);
|
|
} catch (writeErr) {
|
|
throw writeErr;
|
|
}
|
|
}
|
|
|
|
try {
|
|
await updateStorage(`plugins/manifests/${id}.json`, manifest);
|
|
} catch (e) {
|
|
throw e;
|
|
}
|
|
|
|
if (registeredPlugins.has(id)) {
|
|
const existingManifest = registeredPlugins.get(id)!;
|
|
return Object.assign(existingManifest, manifest);
|
|
}
|
|
|
|
return manifest;
|
|
}
|
|
|
|
/**
|
|
* Stops the plugin, fetches the update and restart the updated plugin
|
|
* @param id The registered plugin's ID
|
|
* @param repoUrl URL to the plugin repository. If unprovided, the repository url from the registered plugin will be used.
|
|
*/
|
|
export async function refreshPlugin(id: string, repoUrl?: string) {
|
|
let manifest = registeredPlugins.get(id);
|
|
|
|
assert(manifest, id, "refresh a non-registered plugin");
|
|
assert(pluginInstances.get(id), id, "refresh a non-started plugin");
|
|
|
|
stopPlugin(id);
|
|
|
|
if (isExternalPlugin(manifest)) {
|
|
manifest = await updateAndWritePlugin(
|
|
repoUrl ?? manifest.parentRepository,
|
|
id,
|
|
true,
|
|
);
|
|
}
|
|
|
|
registeredPlugins.delete(id);
|
|
registeredPlugins.set(id, manifest);
|
|
|
|
await startPlugin(id);
|
|
}
|
|
|
|
/**
|
|
* Check for any updates from the repository given, or add it.
|
|
* Then, register all plugins within the repository.
|
|
* @param repoUrl Registered plugin repository url
|
|
* @returns Whether there was any changes made from the update
|
|
*/
|
|
export async function updateRepository(repoUrl: string) {
|
|
// Attempt to fetch a repository descriptor (repo.json). If that fails,
|
|
// try to treat the provided URL as a single-plugin host (manifest.json at root)
|
|
// and synthesize a 1-entry repo mapping. This allows local/dev servers which
|
|
// serve a single plugin at the host root to be consumed by the updater.
|
|
let repo: t.PluginRepo;
|
|
let storedRepo = pluginRepositories[repoUrl];
|
|
|
|
let updated = false;
|
|
|
|
try {
|
|
repo = await fetchJSON(repoUrl, "repo.json");
|
|
} catch (repoErr) {
|
|
// repo.json not found — try fallback candidate manifest at the repo root.
|
|
try {
|
|
const manifest = (await fetchJSON(repoUrl, "manifest.json")) as any;
|
|
// Infer plugin id:
|
|
// Prefer an explicit id property if present; otherwise use name; otherwise
|
|
// fall back to a path-derived token (path or hostname).
|
|
const inferredId =
|
|
manifest?.id ??
|
|
manifest?.name ??
|
|
(() => {
|
|
try {
|
|
const u = new URL(repoUrl);
|
|
const p = u.pathname.replace(/^\/|\/$/g, "");
|
|
return p || u.hostname;
|
|
} catch {
|
|
return "local-plugin";
|
|
}
|
|
})();
|
|
|
|
// Build a minimal repo mapping containing the single manifest entry.
|
|
repo = {
|
|
[inferredId]: manifest,
|
|
// Keep a $meta marker to be consistent with multi-plugin repos (optional)
|
|
$meta: { source: repoUrl },
|
|
} as any;
|
|
|
|
// Persist mapping so other code can read pluginRepositories[repoUrl]
|
|
pluginRepositories[repoUrl] = repo;
|
|
storedRepo = pluginRepositories[repoUrl];
|
|
updated = true;
|
|
} catch (manifestErr) {
|
|
// If both repo.json and manifest.json attempts fail, rethrow the original repo error
|
|
// so callers can handle/report the problem.
|
|
throw repoErr;
|
|
}
|
|
}
|
|
|
|
// This repository never existed, update it!
|
|
if (!storedRepo) {
|
|
for (const id in repo) {
|
|
if (corePluginInstances.has(id)) {
|
|
throw new Error(
|
|
`Plugins can't have the same ID as any of Bunny core plugin '${id}'`,
|
|
);
|
|
}
|
|
}
|
|
|
|
updated = true;
|
|
pluginRepositories[repoUrl] = repo;
|
|
} else {
|
|
// Remove plugins which no longer exists on the fetched repository
|
|
for (const plugin in storedRepo)
|
|
if (!repo[plugin]) {
|
|
delete storedRepo[plugin];
|
|
}
|
|
}
|
|
|
|
const pluginIds = Object.keys(repo).filter((id) => !id.startsWith("$"));
|
|
await Promise.all(
|
|
pluginIds.map(async (pluginId) => {
|
|
if (
|
|
!storedRepo ||
|
|
!storedRepo[pluginId] ||
|
|
repo[pluginId].alwaysFetch ||
|
|
isGreaterVersion(repo[pluginId].version, storedRepo[pluginId].version)
|
|
) {
|
|
updated = true;
|
|
pluginRepositories[repoUrl][pluginId] = repo[pluginId];
|
|
await updateAndWritePlugin(
|
|
repoUrl,
|
|
pluginId,
|
|
Boolean(storedRepo && pluginSettings[pluginId]),
|
|
);
|
|
} else {
|
|
const manifest = await preloadStorageIfExists(
|
|
`plugins/manifests/${pluginId}.json`,
|
|
);
|
|
if (!manifest) {
|
|
// File does not exist, so do refetch and stuff
|
|
await updateAndWritePlugin(
|
|
repoUrl,
|
|
pluginId,
|
|
Boolean(storedRepo && pluginSettings[pluginId]),
|
|
);
|
|
}
|
|
}
|
|
}),
|
|
);
|
|
|
|
// Register plugins in this repository
|
|
for (const id of pluginIds) {
|
|
const manifest = getPreloadedStorage<t.BunnyPluginManifest>(
|
|
`plugins/manifests/${id}.json`,
|
|
);
|
|
if (manifest === undefined) continue; // shouldn't happen, but just incase if it does
|
|
|
|
const existing = registeredPlugins.get(id);
|
|
|
|
// Skip if this version isn't any higher
|
|
if (existing && !isGreaterVersion(manifest.version, existing.version)) {
|
|
continue;
|
|
}
|
|
|
|
registeredPlugins.set(id, manifest);
|
|
}
|
|
|
|
return updated;
|
|
}
|
|
|
|
/**
|
|
* Deletes a repository from registrations and uninstalls ALL plugins under this repository
|
|
*/
|
|
export async function deleteRepository(repoUrl: string) {
|
|
assert(
|
|
repoUrl !== OFFICIAL_PLUGINS_REPO_URL,
|
|
repoUrl,
|
|
"delete the official repository",
|
|
);
|
|
assert(
|
|
pluginRepositories[repoUrl],
|
|
repoUrl,
|
|
"delete a non-registered repository",
|
|
);
|
|
|
|
const promQueues = [] as Promise<unknown>[];
|
|
|
|
for (const [id, manifest] of registeredPlugins) {
|
|
if (!isExternalPlugin(manifest) || manifest.parentRepository !== repoUrl)
|
|
continue;
|
|
|
|
// Uninstall
|
|
if (isPluginInstalled(id)) {
|
|
promQueues.push(uninstallPlugin(id));
|
|
}
|
|
|
|
// Deregister all plugins under this repository
|
|
promQueues.push(purgeStorage(`plugins/manifests/${id}.json`));
|
|
registeredPlugins.delete(id);
|
|
}
|
|
|
|
delete pluginRepositories[repoUrl];
|
|
await Promise.all(promQueues);
|
|
updateAllRepository();
|
|
}
|
|
|
|
/**
|
|
* Enablea a plugin. The plugin must have been declared as installed.
|
|
* @param id The installed plugin ID
|
|
* @param start Whether to start the plugin
|
|
*/
|
|
export async function enablePlugin(id: string, start: boolean) {
|
|
assert(isPluginInstalled(id), id, "enable a non-installed plugin");
|
|
|
|
if (start) await startPlugin(id);
|
|
pluginSettings[id]!.enabled = true;
|
|
}
|
|
|
|
/**
|
|
* Disables and stop the plugin. The plugin must have been declared as installed
|
|
* @param id The installed plugin ID
|
|
*/
|
|
export function disablePlugin(id: string) {
|
|
assert(isPluginInstalled(id), id, "disable a non-installed plugin");
|
|
|
|
pluginInstances.has(id) && stopPlugin(id);
|
|
pluginSettings[id]!.enabled = false;
|
|
}
|
|
|
|
/**
|
|
* Installs a registered plugin, will throw when plugin was already installed
|
|
* @param id The registered plugin ID
|
|
* @param start Whether to start the plugin or not
|
|
*/
|
|
export async function installPlugin(id: string, start: boolean) {
|
|
const manifest = registeredPlugins.get(id);
|
|
|
|
assert(manifest, id, "install an non-registered plugin");
|
|
assert(!isPluginInstalled(id), id, "install an already installed plugin");
|
|
assert(isExternalPlugin(manifest), id, "install a core plugin");
|
|
|
|
// We only need to fetch the JS, but this is fine
|
|
await updateAndWritePlugin(manifest.parentRepository, id, true);
|
|
|
|
pluginSettings[id] = { enabled: true };
|
|
if (start) startPlugin(id);
|
|
}
|
|
|
|
/**
|
|
* Uninstalls a plugin and remove it from the storage
|
|
* @param id The installed plugin ID
|
|
*/
|
|
export async function uninstallPlugin(id: string) {
|
|
const manifest = registeredPlugins.get(id);
|
|
|
|
assert(manifest, id, "uninstall an unregistered plugin");
|
|
assert(isPluginInstalled(id), id, "uninstall a non-installed plugin");
|
|
assert(isExternalPlugin(manifest), id, "uninstall a core plugin");
|
|
|
|
pluginInstances.has(id) && stopPlugin(id);
|
|
delete pluginSettings[id];
|
|
|
|
await purgeStorage(`plugins/storage/${id}.json`);
|
|
await removeFile(`plugins/scripts/${id}.js`);
|
|
}
|
|
|
|
/**
|
|
* Starts a registered, installed, enabled and unstarted plugin. Otherwise, would throw.
|
|
* @param id The enabled plugin ID
|
|
*/
|
|
export async function startPlugin(
|
|
id: string,
|
|
{ throwIfDisabled = false, disableWhenThrown = true } = {},
|
|
) {
|
|
const manifest = registeredPlugins.get(id);
|
|
|
|
assert(manifest, id, "start a non-registered plugin");
|
|
assert(isPluginInstalled(id), id, "start a non-installed plugin");
|
|
assert(
|
|
!throwIfDisabled || pluginSettings[id]?.enabled,
|
|
id,
|
|
"start a disabled plugin",
|
|
);
|
|
assert(!pluginInstances.has(id), id, "start an already started plugin");
|
|
|
|
await preloadStorageIfExists(`plugins/storage/${id}.json`);
|
|
|
|
let pluginInstance: t.PluginInstanceInternal;
|
|
|
|
if (isExternalPlugin(manifest)) {
|
|
// Stage one, "compile" the plugin
|
|
try {
|
|
// jsPath should always exists when the plugin is installed, unless the storage is corrupted
|
|
const iife = await readFile(manifest.jsPath!!);
|
|
|
|
// Set a global marker so runtime/async errors can be attributed to the plugin
|
|
// This is a minimal, best-effort context marker. It will be cleared immediately
|
|
// after the synchronous evaluation step below.
|
|
try {
|
|
(window as any).__SHIGGY_CURRENT_PLUGIN = id;
|
|
} catch {
|
|
// ignore if environment prevents writing to window
|
|
}
|
|
|
|
var instantiator = globalEvalWithSourceUrl(
|
|
`(bunny,definePlugin)=>{${iife};return plugin?.default ?? plugin;}`,
|
|
`bunny-plugin/${id}-${manifest.version}`,
|
|
) as PluginInstantiator;
|
|
} catch (error) {
|
|
// Annotate parsing errors with plugin id to aid attribution in ErrorBoundary
|
|
const e = new Error(
|
|
"An error occured while parsing plugin's code, possibly a syntax error?",
|
|
{ cause: error },
|
|
) as any;
|
|
e.pluginId = id;
|
|
throw e;
|
|
} finally {
|
|
// Clear the immediate evaluation marker - instantiation happens next.
|
|
try {
|
|
(window as any).__SHIGGY_CURRENT_PLUGIN = null;
|
|
} catch {}
|
|
}
|
|
|
|
// Stage two, load the plugin
|
|
try {
|
|
// During instantiation we again mark the current plugin so synchronous
|
|
// work performed by the plugin's top-level code is attributable.
|
|
try {
|
|
(window as any).__SHIGGY_CURRENT_PLUGIN = id;
|
|
} catch {}
|
|
|
|
const api = createBunnyPluginApi(id);
|
|
|
|
// Wrap instantiator call to annotate errors with plugin id if they escape here.
|
|
try {
|
|
pluginInstance = instantiator(api.object, (p) => {
|
|
return Object.assign(p, {
|
|
manifest,
|
|
}) as t.PluginInstanceInternal;
|
|
});
|
|
} catch (innerError) {
|
|
const e = new Error(
|
|
"An error occured while instantiating plugin's code",
|
|
) as any;
|
|
e.cause = innerError;
|
|
e.pluginId = id;
|
|
throw e;
|
|
}
|
|
|
|
if (!pluginInstance) {
|
|
const e = new Error(
|
|
`Plugin '${id}' does not export a valid plugin instance`,
|
|
) as any;
|
|
e.pluginId = id;
|
|
throw e;
|
|
}
|
|
|
|
apiObjects.set(id, api);
|
|
pluginInstances.set(id, pluginInstance);
|
|
} catch (error) {
|
|
// propagate already-annotated error or annotate as fallback
|
|
if (!(error as any).pluginId) (error as any).pluginId = id;
|
|
throw error;
|
|
} finally {
|
|
// Always clear the global plugin marker after instantiation attempt.
|
|
try {
|
|
(window as any).__SHIGGY_CURRENT_PLUGIN = null;
|
|
} catch {}
|
|
}
|
|
} else {
|
|
pluginInstance = corePluginInstances.get(id)!;
|
|
assert(pluginInstance, id, "start a non-existent core plugin");
|
|
pluginInstances.set(id, pluginInstance);
|
|
}
|
|
|
|
// Stage three (of external plugins), start the plugin
|
|
try {
|
|
pluginInstance.start?.();
|
|
|
|
pluginSettings[id]!.enabled = true;
|
|
} catch (error) {
|
|
disableWhenThrown && disablePlugin(id);
|
|
throw new Error("An error occured while starting the plugin", {
|
|
cause: error,
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Stops the plugin and disposes all usages of scoped plugin API
|
|
* @param id The currently-running plugin's ID
|
|
*/
|
|
export function stopPlugin(id: string) {
|
|
const instance = pluginInstances.get(id);
|
|
assert(instance, id, "stop a non-started plugin");
|
|
|
|
instance.stop?.();
|
|
const obj = apiObjects.get(id);
|
|
obj?.disposers.forEach((d: Function) => d());
|
|
pluginInstances.delete(id);
|
|
}
|
|
|
|
/**
|
|
* Utility: produce a sequence of bisect batches for enabled external plugins.
|
|
* This returns an array of arrays where each returned array is the set of plugin
|
|
* ids you should disable in that bisect step (simple binary-halving strategy).
|
|
*
|
|
* The UI can use these batches to guide a manual binary-search disabling flow:
|
|
* - Disable all plugin ids in batches[0], reproduce crash
|
|
* - If crash persists, disable batches[1], otherwise re-enable batches[0] and disable complementary half, etc.
|
|
*
|
|
* This function is intentionally read-only; it only computes batches. The UI
|
|
* should call `disablePlugin(id)` / `enablePlugin(id, ...)` itself to toggle plugins.
|
|
*/
|
|
export function getBisectBatches(maxPerStep = 50): string[][] {
|
|
// Collect external enabled plugin ids
|
|
const enabled: string[] = [];
|
|
for (const [id, manifest] of registeredPlugins) {
|
|
try {
|
|
const isExternal = (manifest as any).parentRepository != null;
|
|
const enabledFlag = Boolean(pluginSettings[id]?.enabled);
|
|
if (!isExternal) continue;
|
|
if (!enabledFlag) continue;
|
|
enabled.push(id);
|
|
} catch {
|
|
// ignore corrupt manifests
|
|
}
|
|
}
|
|
|
|
// Limit to a reasonable number to avoid overwhelming the user
|
|
const ids = enabled.slice(0, maxPerStep);
|
|
|
|
if (ids.length <= 1) return ids.length === 1 ? [[ids[0]]] : [];
|
|
|
|
// Binary-halving batches:
|
|
// On step n, disable the first half of the current candidate set.
|
|
const batches: string[][] = [];
|
|
let candidates = ids.slice();
|
|
while (candidates.length > 1) {
|
|
const half = Math.ceil(candidates.length / 2);
|
|
const toDisable = candidates.slice(0, half);
|
|
batches.push(toDisable);
|
|
// For the next iteration assume we will test the complement; compute complement
|
|
candidates = candidates.slice(half);
|
|
// If complement has only one left, add it as final step
|
|
if (candidates.length === 1) {
|
|
batches.push([candidates[0]]);
|
|
break;
|
|
}
|
|
}
|
|
|
|
return batches;
|
|
}
|
|
|
|
export async function updateAllRepository() {
|
|
// Always attempt to update the official repository first
|
|
try {
|
|
await updateRepository(OFFICIAL_PLUGINS_REPO_URL);
|
|
} catch (error) {
|
|
console.error("Failed to update official plugins repository", error);
|
|
}
|
|
|
|
// Determine global fetch-on-start behavior and per-repo overrides
|
|
const fetchOnStart = !!(updaterSettings.fetchPluginsOnStart ?? true);
|
|
const overrides = updaterSettings.repoAutoFetchOverrides ?? {};
|
|
|
|
const repos = Object.keys(pluginRepositories);
|
|
|
|
await Promise.allSettled(
|
|
repos.map(async (repo) => {
|
|
// Skip official (already handled)
|
|
if (repo === OFFICIAL_PLUGINS_REPO_URL) return;
|
|
|
|
// If global fetching is disabled, only process repos explicitly overridden to true
|
|
const repoOverride = overrides[repo];
|
|
if (!fetchOnStart && repoOverride !== true) return;
|
|
|
|
try {
|
|
await updateRepository(repo);
|
|
} catch (e) {
|
|
console.error(`Failed to update repository ${repo}`, e);
|
|
}
|
|
}),
|
|
);
|
|
}
|
|
|
|
export async function updatePlugins() {
|
|
await awaitStorage(pluginRepositories, pluginSettings);
|
|
|
|
// Register core plugins
|
|
const corePlugins = getCorePlugins();
|
|
for (const id in corePlugins) {
|
|
const { default: instance, preenabled } = corePlugins[id];
|
|
|
|
// Core plugins are always installed
|
|
pluginSettings[id] ??= {
|
|
enabled: preenabled ?? true,
|
|
};
|
|
|
|
registeredPlugins.set(id, instance.manifest);
|
|
corePluginInstances.set(id, instance);
|
|
}
|
|
|
|
// Always ensure official repository metadata is at least attempted to be fetched,
|
|
// because core plugin registration / system behavior may depend on it.
|
|
try {
|
|
await updateRepository(OFFICIAL_PLUGINS_REPO_URL);
|
|
} catch (error) {
|
|
console.error("Failed to update official plugins repository", error);
|
|
}
|
|
|
|
// If fetch-on-start is enabled, update all repositories following configured policy.
|
|
// Otherwise, only update repositories which have per-repo overrides explicitly set to true.
|
|
const fetchOnStart = !!(updaterSettings.fetchPluginsOnStart ?? true);
|
|
const overrides = updaterSettings.repoAutoFetchOverrides ?? {};
|
|
|
|
if (fetchOnStart) {
|
|
await updateAllRepository();
|
|
} else {
|
|
// Only fetch explicitly opted-in repositories to avoid startup slowdowns.
|
|
const repos = Object.keys(pluginRepositories).filter(
|
|
(r) => r !== OFFICIAL_PLUGINS_REPO_URL && overrides[r] === true,
|
|
);
|
|
await Promise.allSettled(
|
|
repos.map(async (repo) => {
|
|
try {
|
|
await updateRepository(repo);
|
|
} catch (e) {
|
|
console.error(`Failed to update repository ${repo}`, e);
|
|
}
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Initialize plugins but stagger startup to avoid CPU spikes during app start.
|
|
*
|
|
* This replaces the original "start all enabled plugins in parallel" approach
|
|
* with a configurable staggered startup: plugins are started in small batches and
|
|
* we await a small interval between batches. This reduces short-lived CPU and I/O contention
|
|
* that causes jank on low-end devices.
|
|
*
|
|
* Backwards-compatible: callers can still call initPlugins() with no arguments.
|
|
*
|
|
* Options:
|
|
* - staggerInterval (ms) - wait time between batches (default: 100)
|
|
* - batchSize - how many plugins to start concurrently in a single batch (default: 3)
|
|
*/
|
|
export async function initPlugins(
|
|
options: { staggerInterval?: number; batchSize?: number } = {},
|
|
) {
|
|
const { staggerInterval = 100, batchSize = 3 } = options;
|
|
|
|
// Ensure core plugins are registered prior to any plugin startup logic so
|
|
// settings pages and UI that read registered/core plugins show them.
|
|
// registerCorePlugins is synchronous and idempotent, but call it here as a
|
|
// safety-net in case module-load registration didn't run earlier.
|
|
try {
|
|
registerCorePlugins();
|
|
} catch {
|
|
// ignore
|
|
}
|
|
|
|
await awaitStorage(pluginRepositories, pluginSettings);
|
|
|
|
// Collect enabled plugin ids
|
|
const enabledIds = [...registeredPlugins.keys()].filter((id) =>
|
|
isPluginEnabled(id),
|
|
);
|
|
|
|
if (enabledIds.length === 0) return;
|
|
|
|
// Helper sleep
|
|
const sleep = (ms: number) => new Promise<void>((res) => setTimeout(res, ms));
|
|
|
|
// Start plugins in batches to avoid starting all at once.
|
|
for (let i = 0; i < enabledIds.length; i += batchSize) {
|
|
const batch = enabledIds.slice(i, i + batchSize);
|
|
|
|
// Start the batch in parallel but don't let exceptions bubble to halt everything.
|
|
await Promise.allSettled(
|
|
batch.map(async (id) => {
|
|
try {
|
|
// Only attempt to start if still enabled and not already started
|
|
if (!isPluginEnabled(id) || pluginInstances.has(id)) return;
|
|
await startPlugin(id).catch((e) => {
|
|
// startPlugin may throw; ensure it doesn't crash the whole batch
|
|
console.error(`Failed to start plugin ${id}:`, e);
|
|
});
|
|
} catch (error) {
|
|
console.error(`Unexpected error while starting plugin ${id}:`, error);
|
|
}
|
|
}),
|
|
);
|
|
|
|
// If there are more plugins to start, wait before starting the next batch.
|
|
if (i + batchSize < enabledIds.length) {
|
|
await sleep(staggerInterval);
|
|
}
|
|
}
|
|
}
|