Defer plugin and network initialization for faster UI startup

Split app initialization into critical UI tasks and deferred
plugin/network work. Plugins and repository updates now start after
initial interactions, reducing startup jank. Plugin startup is staggered
in batches to avoid CPU spikes. Adds new sort options for plugin lists
and improves plugin registration logic. Removes unused settings and
fixes duplicate tab insertion.
This commit is contained in:
kmmiio99o 2025-10-13 01:02:05 +02:00
parent 52116478d5
commit e66290334c
10 changed files with 640 additions and 352 deletions

View file

@ -33,6 +33,9 @@ const config = {
outfile: "dist/shiggycord.js",
format: "iife",
splitting: false,
// Enable minification by default for release builds (when a release-branch is provided).
// This keeps development builds unminified for easier debugging.
minify: Boolean(releaseBranch),
external: [],
supported: {
// Hermes does not actually support const and let, even though it syntactically
@ -74,7 +77,7 @@ const config = {
constModules: {
globals: {
"bunny-build-info": {
version: `"dev1.1.1-1"`,
version: `"dev1.1.0-2"`,
},
},
},

View file

@ -8,20 +8,22 @@
import { logger } from "@lib/utils/logger";
import { FluxDispatcher } from "@metro/common";
import moment from "moment";
function onDispatch({ locale }: { locale: string; }) {
// Timestamps
try {
moment.locale(locale.toLowerCase());
} catch (e) {
logger.error("Failed to fix timestamps...", e);
}
function onDispatch({ locale }: { locale: string }) {
// Timestamps - require moment lazily so we avoid top-level import cost
try {
// require at runtime (lazy) to keep startup/bundle lighter
// eslint-disable-next-line @typescript-eslint/no-var-requires
const moment = require("moment");
moment.locale(locale.toLowerCase());
} catch (e) {
logger.error("Failed to fix timestamps (lazy moment)...", e);
}
// We're done here!
FluxDispatcher.unsubscribe("I18N_LOAD_SUCCESS", onDispatch);
// We're done here!
FluxDispatcher.unsubscribe("I18N_LOAD_SUCCESS", onDispatch);
}
export default () => {
FluxDispatcher.subscribe("I18N_LOAD_SUCCESS", onDispatch);
FluxDispatcher.subscribe("I18N_LOAD_SUCCESS", onDispatch);
};

View file

@ -14,7 +14,6 @@ const { TableRowGroup, TableRow, TableSwitchRow, Stack } = findByProps(
const { ScrollView, Text } = require("react-native");
type FixEmbedSettings = {
enabled: boolean;
twitter: boolean;
instagram: boolean;
tiktok: boolean;
@ -86,7 +85,6 @@ export default defineCorePlugin({
SettingsComponent() {
const { useState, useEffect } = React;
const [config, setConfig] = useState<FixEmbedSettings>({
enabled: settings.fixembed?.enabled ?? true,
twitter: settings.fixembed?.twitter ?? true,
instagram: settings.fixembed?.instagram ?? true,
tiktok: settings.fixembed?.tiktok ?? true,
@ -132,16 +130,7 @@ export default defineCorePlugin({
React.createElement(
Stack,
{ spacing: 8, style: { padding: 10 } },
React.createElement(
TableRowGroup,
{ title: "General Settings" },
React.createElement(TableSwitchRow, {
label: "Enable FixEmbed",
subLabel: "Master switch for all link conversions",
value: config.enabled,
onValueChange: (v: boolean) => updateConfig("enabled", v),
}),
),
React.createElement(
TableRowGroup,
{ title: "Platforms" },
@ -149,25 +138,21 @@ export default defineCorePlugin({
label: "Twitter/X",
value: config.twitter,
onValueChange: (v: boolean) => updateConfig("twitter", v),
disabled: !config.enabled,
}),
React.createElement(TableSwitchRow, {
label: "Instagram",
value: config.instagram,
onValueChange: (v: boolean) => updateConfig("instagram", v),
disabled: !config.enabled,
}),
React.createElement(TableSwitchRow, {
label: "TikTok",
value: config.tiktok,
onValueChange: (v: boolean) => updateConfig("tiktok", v),
disabled: !config.enabled,
}),
React.createElement(TableSwitchRow, {
label: "Reddit",
value: config.reddit,
onValueChange: (v: boolean) => updateConfig("reddit", v),
disabled: !config.enabled,
}),
),
React.createElement(
@ -187,7 +172,6 @@ export default defineCorePlugin({
start() {
logger.log("FixEmbed: Starting plugin");
settings.fixembed = settings.fixembed || {
enabled: true,
twitter: true,
instagram: true,
tiktok: true,
@ -197,7 +181,6 @@ export default defineCorePlugin({
if (!unpatch) {
unpatch = after("sendMessage", MessageActions, (args) => {
const config = settings.fixembed!;
if (!config.enabled) return;
if (args[1]?.content) {
args[1].content = transformLinks(args[1].content, config);
args[1].nonce = args[1].nonce || Math.random().toString(36).slice(2);

View file

@ -4,7 +4,7 @@ import { logger } from "@lib/utils/logger";
import { settings } from "@lib/api/settings";
import { React } from "@metro/common";
import { showToast } from "@lib/ui/toasts";
import { ScrollView } from "react-native";
import { ScrollView, Text } from "react-native";
// Button imported from components above
@ -87,22 +87,22 @@ export default defineCorePlugin({
SettingsComponent() {
const { useState, useEffect } = React;
// Use table-style rows (TableRowGroup / TableSwitchRow) for settings UI.
// Resolve UI row components; guard if unavailable.
const { TableRow, TableRowGroup, TableSwitchRow } = findByProps(
// Resolve UI row components; prefer table-style rows and Stack for consistent layout.
const { TableRow, TableRowGroup, TableSwitchRow, Stack } = findByProps(
"TableRow",
"TableRowGroup",
"TableSwitchRow",
"Stack",
);
if (!TableRow || !TableRowGroup || !TableSwitchRow) {
if (!TableRow || !TableRowGroup || !TableSwitchRow || !Stack) {
return React.createElement(
ScrollView,
{ style: { flex: 1, padding: 12 } },
React.createElement(
Text,
null,
"ShiggyHelper UI unavailable (missing TableRow components).",
"ShiggyHelper UI unavailable (missing TableRow / Stack components).",
),
);
}
@ -144,69 +144,72 @@ export default defineCorePlugin({
return React.createElement(ScrollView, { style: { flex: 1 } }, [
React.createElement(
TableRowGroup,
{ title: "ShiggyHelper" },
React.createElement(TableSwitchRow, {
label: "Verbose Logging",
subLabel:
"Capture console.log and console.error into internal buffer",
value: verbose,
onValueChange: (v: boolean) => setVerbose(v),
icon: null,
}),
),
Stack,
{ spacing: 8, style: { padding: 10 } },
React.createElement(
TableRowGroup,
{ title: "ShiggyHelper" },
React.createElement(TableSwitchRow, {
label: "Verbose Logging",
subLabel:
"Capture console.log and console.error into internal buffer",
value: verbose,
onValueChange: (v: boolean) => setVerbose(v),
}),
),
React.createElement(
TableRowGroup,
{ title: "Actions" },
React.createElement(TableRow, {
key: "dump",
label: "Dump Logs",
subLabel: "Write recent captured logs to the debug logger",
trailing: React.createElement(TableRow.TrailingText, {
text: "Dump",
React.createElement(
TableRowGroup,
{ title: "Actions" },
React.createElement(TableRow, {
key: "dump",
label: "Dump Logs",
subLabel: "Write recent captured logs to the debug logger",
trailing: React.createElement(TableRow.TrailingText, {
text: "Dump",
}),
onPress: () => {
if (actionsDisabled) {
showToast("Enable verbose logging first");
return;
}
dumpLogs();
},
disabled: actionsDisabled,
}),
onPress: () => {
if (actionsDisabled) {
showToast("Enable verbose logging first");
return;
}
dumpLogs();
},
disabled: actionsDisabled,
}),
React.createElement(TableRow, {
key: "clear",
label: "Clear Logs",
subLabel: "Clear the internal captured log buffer",
trailing: React.createElement(TableRow.TrailingText, {
text: "Clear",
React.createElement(TableRow, {
key: "clear",
label: "Clear Logs",
subLabel: "Clear the internal captured log buffer",
trailing: React.createElement(TableRow.TrailingText, {
text: "Clear",
}),
onPress: () => {
if (actionsDisabled) {
showToast("Enable verbose logging first");
return;
}
clearLogs();
},
disabled: actionsDisabled,
}),
onPress: () => {
if (actionsDisabled) {
showToast("Enable verbose logging first");
return;
}
clearLogs();
},
disabled: actionsDisabled,
}),
React.createElement(TableRow, {
key: "emit",
label: "Emit Test Logs",
subLabel: "Emit a test console.log / console.error entry",
trailing: React.createElement(TableRow.TrailingText, {
text: "Emit",
React.createElement(TableRow, {
key: "emit",
label: "Emit Test Logs",
subLabel: "Emit a test console.log / console.error entry",
trailing: React.createElement(TableRow.TrailingText, {
text: "Emit",
}),
onPress: () => {
if (actionsDisabled) {
showToast("Enable verbose logging first");
return;
}
triggerTestLog();
},
disabled: actionsDisabled,
}),
onPress: () => {
if (actionsDisabled) {
showToast("Enable verbose logging first");
return;
}
triggerTestLog();
},
disabled: actionsDisabled,
}),
),
),
]);
},

View file

@ -20,7 +20,6 @@ import {
TableSwitchRow,
} from "@metro/common/components";
import { Linking, ScrollView } from "react-native";
import CodebergIcon from "@assets/icons/codeberg-logo_icon_white.png";
export default function General() {
useProxy(settings);

View file

@ -387,6 +387,33 @@ function InstalledPluginPage(props: PluginPageProps) {
.join() || "",
]}
sortOptions={{
"Enabled First": (a, b) =>
Number(b.isEnabled()) - Number(a.isEnabled()),
"Disabled First": (a, b) =>
Number(a.isEnabled()) - Number(b.isEnabled()),
"Date (Newest)": (a, b) => {
// Prefer recently installed/registered plugins first. We look up insertion
// order from Vendetta manager and Bunny pluginSettings; later entries are
// considered newer (higher index). If missing, treat index as -1.
const vdOrder = Object.keys(VdPluginManager.plugins || {});
const bnOrder = Object.keys(pluginSettings || {});
const idx = (id: string) => {
const vdI = vdOrder.indexOf(id);
const bnI = bnOrder.indexOf(id);
return Math.max(vdI, bnI);
};
return idx(b.id) - idx(a.id);
},
"Date (Oldest)": (a, b) => {
const vdOrder = Object.keys(VdPluginManager.plugins || {});
const bnOrder = Object.keys(pluginSettings || {});
const idx = (id: string) => {
const vdI = vdOrder.indexOf(id);
const bnI = bnOrder.indexOf(id);
return Math.max(vdI, bnI);
};
return idx(a.id) - idx(b.id);
},
"Name (A-Z)": (a, b) => a.name.localeCompare(b.name),
"Name (Z-A)": (a, b) => b.name.localeCompare(a.name),
}}
@ -405,6 +432,51 @@ interface BrowsePluginPageProps {
}
function BrowsePluginPage(props: BrowsePluginPageProps) {
// Build quick lookup/index map based on the order fetched from remote repository.
// The remote list doesn't provide timestamps, so we treat the list order as the
// chronological order from oldest -> newest and use indices for Date sorting.
const indexMap = new Map<string, number>();
(props.plugins || []).forEach((p, i) =>
indexMap.set(normalizeIdFromInstallUrl(p.installUrl), i),
);
// Helpers to determine install / enabled state.
const findBunnyIdForAddon = (addon: PluginData) => {
for (const [id, manifest] of registeredPlugins) {
if (!manifest) continue;
const name = manifest.display?.name ?? id;
if (name === addon.name) return id;
}
return null;
};
const isEnabledForAddon = (addon: PluginData) => {
const vendettaId = normalizeIdFromInstallUrl(addon.installUrl);
const vd = VdPluginManager.plugins[vendettaId];
if (vd) return Boolean(vd.enabled ?? true);
const bnId = findBunnyIdForAddon(addon);
if (bnId) return Boolean(pluginSettings[bnId]?.enabled);
return false;
};
// Helper to determine whether an addon from the remote list is installed locally.
const isInstalledForAddon = (addon: PluginData) => {
const vendettaId = normalizeIdFromInstallUrl(addon.installUrl);
// Vendetta-managed plugin presence indicates installed
if (VdPluginManager.plugins[vendettaId]) return true;
// For Bunny-registered plugins, try to resolve by display name -> id and check pluginSettings
const bnId = findBunnyIdForAddon(addon);
if (bnId) return Boolean(pluginSettings[bnId] != null);
return false;
};
const getIndexForAddon = (addon: PluginData) =>
indexMap.get(normalizeIdFromInstallUrl(addon.installUrl)) ?? 0;
return (
<AddonPage<PluginData>
CardComponent={({ item }) => (
@ -422,8 +494,47 @@ function BrowsePluginPage(props: BrowsePluginPageProps) {
(p) => (p.authors || []).join(", "),
]}
sortOptions={{
"Date (Newest)": (a, b) => 0,
"Date (Oldest)": (a, b) => 0,
// Newest first -> larger index is newer (we treat list order as chronological)
"Date (Newest)": (a, b) => getIndexForAddon(b) - getIndexForAddon(a),
// Oldest first -> smaller index is older
"Date (Oldest)": (a, b) => getIndexForAddon(a) - getIndexForAddon(b),
// Health-based sorts: surface problematic plugins first or working ones first
"Broken First": (a, b) => {
const score = (p: PluginData) =>
p.status === "broken"
? 3
: p.status === "warning"
? 2
: p.status === "working"
? 1
: 0;
return score(b) - score(a);
},
"Warning First": (a, b) => {
const score = (p: PluginData) =>
p.status === "warning"
? 3
: p.status === "broken"
? 2
: p.status === "working"
? 1
: 0;
return score(b) - score(a);
},
"Working First": (a, b) => {
const score = (p: PluginData) =>
p.status === "working"
? 3
: p.status === "warning"
? 2
: p.status === "broken"
? 1
: 0;
return score(b) - score(a);
},
// Keep name sorts
"Name (A-Z)": (a, b) => a.name.localeCompare(b.name),
"Name (Z-A)": (a, b) => b.name.localeCompare(a.name),
}}
@ -495,22 +606,33 @@ export default function Plugins() {
useProxy(VdPluginManager.plugins);
useObservable([pluginSettings]);
const vdPlugins = Object.values(VdPluginManager.plugins).map(
unifyVdPlugin,
);
// Vendetta plugins: preserve VdPluginManager.plugins insertion order,
// but display most recently added first (reverse the keys).
const vdIds = Object.keys(VdPluginManager.plugins || {});
const vdPlugins = vdIds
.slice()
.reverse()
.map((id) => unifyVdPlugin(VdPluginManager.plugins[id]));
// Core plugins (always shown first)
// Bunny external plugins which are installed (non-core).
// Use the insertion order of pluginSettings so newly-installed plugins
// (which add entries to pluginSettings) appear first.
const installedBnIds = Object.keys(pluginSettings || {})
.filter((id) => registeredPlugins.has(id) && !isCorePlugin(id))
.slice()
.reverse();
const bnPlugins = installedBnIds
.map((id) => registeredPlugins.get(id)!)
.map(unifyBunnyPlugin);
// Keep core plugins registration available if needed elsewhere
const corePlugins = [...corePluginInstances.keys()]
.map((id) => registeredPlugins.get(id))
.filter(Boolean)
.map((id) => registeredPlugins.get(id)!)
.map(unifyBunnyPlugin);
// Regular installed plugins (non-core)
const bnPlugins = [...registeredPlugins.values()]
.filter((p) => isPluginInstalled(p.id) && !isCorePlugin(p.id))
.map(unifyBunnyPlugin);
// Show only Vendetta plugins + non-core Bunny plugins (exclude core plugins)
// Merge lists: show Vendetta-managed plugins first (recent first),
// then Bunny-installed externals (recent first).
return [...vdPlugins, ...bnPlugins];
}}
ListHeaderComponent={() => null}

View file

@ -13,12 +13,17 @@ import { injectFluxInterceptor } from "@lib/api/flux";
import { patchJsx } from "@lib/api/react/jsx";
import { logger } from "@lib/utils/logger";
import { patchSettings } from "@ui/settings";
import { InteractionManager } from "react-native";
import * as lib from "./lib";
/**
* Start sequence split into critical (UI) and deferred (network/plugin) work.
* The goal is to get the UI ready quickly and run heavy tasks after interactions.
*/
export default async () => {
// Load everything in parallel
await Promise.all([
// Critical initializers that are required for the app's UI and basic features.
const criticalInits = [
initThemes(),
injectFluxInterceptor(),
patchSettings(),
@ -29,27 +34,64 @@ export default async () => {
initFetchI18nStrings(),
initSettings(),
initFixes(),
// patchErrorBoundary(),
updatePlugins(),
]).then(
// Push them all to unloader
(u) => u.forEach((f) => f && lib.unload.push(f)),
);
// Do NOT run updatePlugins here — that is deferred.
];
// Assign window object
// Run critical inits and collect unpatchers/cleanup handlers.
await Promise.all(criticalInits)
.then((u) => u.forEach((f) => f && lib.unload.push(f)))
.catch((e) => {
// Log but don't abort — critical inits failing should be visible in logs.
console.warn("Critical initialization error:", e);
});
// Expose the library object early so UI and other code can access window.bunny.
window.bunny = lib;
// Once done, load Vendetta plugins
VdPluginManager.initPlugins()
.then((u) => lib.unload.push(u))
.catch(() => alert("Failed to initialize Vendetta plugins"));
logger.log(
"ShiggyCord: UI-critical initialization complete — deferring plugin & network work",
);
// And then, load ShiggyCord plugins
initPlugins();
// Deferred work: run after interactions to avoid blocking initial paint and navigation.
const runDeferred = () => {
// Initialize Vendetta plugins (may start many plugins) — do not block UI.
VdPluginManager.initPlugins()
.then((u) => lib.unload.push(u))
.catch((e) => logger.log("Vendetta init failed:", e));
// Update the fonts
updateFonts();
// Start ShiggyCord (Bunny) plugins (reads storage and may compile/instantiate plugins).
// Use staggered startup options to reduce CPU spikes while still starting multiple plugins quickly.
initPlugins({ staggerInterval: 50, batchSize: 5 });
// We good :)
logger.log("ShiggyCord is ready!");
// Perform an immediate (deferred) repository update; network-heavy.
updatePlugins().catch((e) => logger.log("updatePlugins failed:", e));
// Update fonts in background
updateFonts().catch((e) => logger.log("updateFonts failed:", e));
};
// Preferred: wait until interactions finish (animations / navigation).
try {
InteractionManager.runAfterInteractions(() => {
// small delay to ensure native lifecycle settled
setTimeout(runDeferred, 50);
});
} catch (e) {
// Fallback if InteractionManager isn't available for any reason.
setTimeout(runDeferred, 200);
}
// Additionally schedule a periodic attempt to update plugins ~3 minutes after start.
// This ensures plugin repositories get refreshed even if deferred tasks were delayed.
setTimeout(
() => {
updatePlugins().catch((e) =>
logger.log("Scheduled updatePlugins (3min) failed:", e),
);
},
3 * 60 * 1000,
);
// Final ready log for basic UI availability.
logger.log("ShiggyCord is ready (UI available).");
};

View file

@ -1,6 +1,13 @@
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 {
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";
@ -9,8 +16,8 @@ import { createBunnyPluginApi } from "./api";
import * as t from "./types";
type PluginInstantiator = (
bunny: t.BunnyPluginObject,
definePlugin?: (p: t.PluginInstance) => t.PluginInstanceInternal
bunny: t.BunnyPluginObject,
definePlugin?: (p: t.PluginInstance) => t.PluginInstanceInternal,
) => t.PluginInstanceInternal;
// Core plugins instances are stored both in this and pluginInstance
@ -20,17 +27,31 @@ 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>>();
export const apiObjects = new Map<
string,
ReturnType<typeof createBunnyPluginApi>
>();
export const pluginRepositories = createStorage<t.PluginRepoStorage>("plugins/repositories.json");
export const pluginSettings = createStorage<t.PluginSettingsStorage>("plugins/settings.json");
export const pluginRepositories = createStorage<t.PluginRepoStorage>(
"plugins/repositories.json",
);
export const pluginSettings = createStorage<t.PluginSettingsStorage>(
"plugins/settings.json",
);
const _fetch = (repoUrl: string, path: string) => 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());
const _fetch = (repoUrl: string, path: string) =>
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}`);
function assert<T>(
condition: T,
id: string,
attempt: string,
): asserts condition {
if (!condition) throw new Error(`[${id}] Attempted to ${attempt}`);
}
/**
@ -40,34 +61,38 @@ function assert<T>(condition: T, id: string, attempt: string): asserts condition
* @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);
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;
function isExternalPlugin(
manifest: t.BunnyPluginManifest,
): manifest is t.BunnyPluginManifestInternal {
return "parentRepository" in manifest;
}
export function isCorePlugin(id: string) {
return corePluginInstances.has(id);
return corePluginInstances.has(id);
}
export function getPluginSettingsComponent(id: string): React.ComponentType<any> | null {
const instance = pluginInstances.get(id);
if (!instance) return null;
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;
if (instance.SettingsComponent) return instance.SettingsComponent;
return null;
}
export function isPluginInstalled(id: string) {
return pluginSettings[id] != null;
return pluginSettings[id] != null;
}
export function isPluginEnabled(id: string) {
return Boolean(pluginSettings[id]?.enabled);
return Boolean(pluginSettings[id]?.enabled);
}
/**
@ -76,28 +101,35 @@ export function isPluginEnabled(id: string) {
* @param id The ID of the plugin
* @returns The newly fetched plugin manifest
*/
export async function updateAndWritePlugin(repoUrl: string, id: string, fetchScript: boolean) {
const manifest: t.BunnyPluginManifestInternal = await fetchJSON(repoUrl, `builds/${id}/manifest.json`);
export async function updateAndWritePlugin(
repoUrl: string,
id: string,
fetchScript: boolean,
) {
const manifest: t.BunnyPluginManifestInternal = await fetchJSON(
repoUrl,
`builds/${id}/manifest.json`,
);
// @ts-expect-error - Setting a readonly property
manifest.parentRepository = repoUrl;
if (fetchScript) {
// @ts-expect-error - Setting a readonly property
manifest.parentRepository = repoUrl;
manifest.jsPath = `plugins/scripts/${id}.js`;
if (fetchScript) {
// @ts-expect-error - Setting a readonly property
manifest.jsPath = `plugins/scripts/${id}.js`;
const js: string = await fetchJS(repoUrl, `builds/${id}/index.js`);
await writeFile(manifest.jsPath, js);
}
const js: string = await fetchJS(repoUrl, `builds/${id}/index.js`);
await writeFile(manifest.jsPath, js);
}
await updateStorage(`plugins/manifests/${id}.json`, manifest);
await updateStorage(`plugins/manifests/${id}.json`, manifest);
if (registeredPlugins.has(id)) {
const existingManifest = registeredPlugins.get(id)!;
return Object.assign(existingManifest, manifest);
}
if (registeredPlugins.has(id)) {
const existingManifest = registeredPlugins.get(id)!;
return Object.assign(existingManifest, manifest);
}
return manifest;
return manifest;
}
/**
@ -106,21 +138,25 @@ export async function updateAndWritePlugin(repoUrl: string, id: string, fetchScr
* @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);
let manifest = registeredPlugins.get(id);
assert(manifest, id, "refresh a non-registered plugin");
assert(pluginInstances.get(id), id, "refresh a non-started plugin");
assert(manifest, id, "refresh a non-registered plugin");
assert(pluginInstances.get(id), id, "refresh a non-started plugin");
stopPlugin(id);
stopPlugin(id);
if (isExternalPlugin(manifest)) {
manifest = await updateAndWritePlugin(repoUrl ?? manifest.parentRepository, id, true);
}
if (isExternalPlugin(manifest)) {
manifest = await updateAndWritePlugin(
repoUrl ?? manifest.parentRepository,
id,
true,
);
}
registeredPlugins.delete(id);
registeredPlugins.set(id, manifest);
registeredPlugins.delete(id);
registeredPlugins.set(id, manifest);
await startPlugin(id);
await startPlugin(id);
}
/**
@ -130,85 +166,117 @@ export async function refreshPlugin(id: string, repoUrl?: string) {
* @returns Whether there was any changes made from the update
*/
export async function updateRepository(repoUrl: string) {
const repo: t.PluginRepo = await fetchJSON(repoUrl, "repo.json");
const storedRepo = pluginRepositories[repoUrl];
const repo: t.PluginRepo = await fetchJSON(repoUrl, "repo.json");
const storedRepo = pluginRepositories[repoUrl];
let updated = false;
let updated = false;
// 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}'`);
}
}
// 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] = repo;
} else {
// Remove plugins which no longer exists on the fetched repository
for (const plugin in storedRepo) if (!repo[plugin]) {
delete storedRepo[plugin];
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;
}
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]));
}
}
}));
registeredPlugins.set(id, manifest);
}
// 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;
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");
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>[];
const promQueues = [] as Promise<unknown>[];
for (const [id, manifest] of registeredPlugins) {
if (!isExternalPlugin(manifest) || manifest.parentRepository !== repoUrl) continue;
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);
// Uninstall
if (isPluginInstalled(id)) {
promQueues.push(uninstallPlugin(id));
}
delete pluginRepositories[repoUrl];
await Promise.all(promQueues);
updateAllRepository();
// Deregister all plugins under this repository
promQueues.push(purgeStorage(`plugins/manifests/${id}.json`));
registeredPlugins.delete(id);
}
delete pluginRepositories[repoUrl];
await Promise.all(promQueues);
updateAllRepository();
}
/**
@ -217,10 +285,10 @@ export async function deleteRepository(repoUrl: string) {
* @param start Whether to start the plugin
*/
export async function enablePlugin(id: string, start: boolean) {
assert(isPluginInstalled(id), id, "enable a non-installed plugin");
assert(isPluginInstalled(id), id, "enable a non-installed plugin");
if (start) await startPlugin(id);
pluginSettings[id]!.enabled = true;
if (start) await startPlugin(id);
pluginSettings[id]!.enabled = true;
}
/**
@ -228,10 +296,10 @@ export async function enablePlugin(id: string, start: boolean) {
* @param id The installed plugin ID
*/
export function disablePlugin(id: string) {
assert(isPluginInstalled(id), id, "disable a non-installed plugin");
assert(isPluginInstalled(id), id, "disable a non-installed plugin");
pluginInstances.has(id) && stopPlugin(id);
pluginSettings[id]!.enabled = false;
pluginInstances.has(id) && stopPlugin(id);
pluginSettings[id]!.enabled = false;
}
/**
@ -240,17 +308,17 @@ export function disablePlugin(id: string) {
* @param start Whether to start the plugin or not
*/
export async function installPlugin(id: string, start: boolean) {
const manifest = registeredPlugins.get(id);
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");
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);
// 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);
pluginSettings[id] = { enabled: true };
if (start) startPlugin(id);
}
/**
@ -258,78 +326,96 @@ export async function installPlugin(id: string, start: boolean) {
* @param id The installed plugin ID
*/
export async function uninstallPlugin(id: string) {
const manifest = registeredPlugins.get(id);
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");
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];
pluginInstances.has(id) && stopPlugin(id);
delete pluginSettings[id];
await purgeStorage(`plugins/storage/${id}.json`);
await removeFile(`plugins/scripts/${id}.js`);
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);
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");
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`);
await preloadStorageIfExists(`plugins/storage/${id}.json`);
let pluginInstance: t.PluginInstanceInternal;
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!!);
var instantiator = globalEvalWithSourceUrl(
`(bunny,definePlugin)=>{${iife};return plugin?.default ?? plugin;}`,
`bunny-plugin/${id}-${manifest.version}`
) as PluginInstantiator;
} catch (error) {
throw new Error("An error occured while parsing plugin's code, possibly a syntax error?", { cause: error });
}
// Stage two, load the plugin
try {
const api = createBunnyPluginApi(id);
pluginInstance = instantiator(api.object, p => {
return Object.assign(p, {
manifest
}) as t.PluginInstanceInternal;
});
if (!pluginInstance) throw new Error(`Plugin '${id}' does not export a valid plugin instance`);
apiObjects.set(id, api);
pluginInstances.set(id, pluginInstance);
} catch (error) {
throw new Error("An error occured while instantiating plugin's code", { cause: error });
}
} 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
if (isExternalPlugin(manifest)) {
// Stage one, "compile" the plugin
try {
pluginInstance.start?.();
pluginSettings[id]!.enabled = true;
// jsPath should always exists when the plugin is installed, unless the storage is corrupted
const iife = await readFile(manifest.jsPath!!);
var instantiator = globalEvalWithSourceUrl(
`(bunny,definePlugin)=>{${iife};return plugin?.default ?? plugin;}`,
`bunny-plugin/${id}-${manifest.version}`,
) as PluginInstantiator;
} catch (error) {
disableWhenThrown && disablePlugin(id);
throw new Error("An error occured while starting the plugin", { cause: error });
throw new Error(
"An error occured while parsing plugin's code, possibly a syntax error?",
{ cause: error },
);
}
// Stage two, load the plugin
try {
const api = createBunnyPluginApi(id);
pluginInstance = instantiator(api.object, (p) => {
return Object.assign(p, {
manifest,
}) as t.PluginInstanceInternal;
});
if (!pluginInstance)
throw new Error(
`Plugin '${id}' does not export a valid plugin instance`,
);
apiObjects.set(id, api);
pluginInstances.set(id, pluginInstance);
} catch (error) {
throw new Error("An error occured while instantiating plugin's code", {
cause: error,
});
}
} 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,
});
}
}
/**
@ -337,62 +423,105 @@ export async function startPlugin(id: string, { throwIfDisabled = false, disable
* @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");
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);
instance.stop?.();
const obj = apiObjects.get(id);
obj?.disposers.forEach((d: Function) => d());
pluginInstances.delete(id);
}
export async function updateAllRepository() {
try {
await updateRepository(OFFICIAL_PLUGINS_REPO_URL);
} catch (error) {
console.error("Failed to update official plugins repository", error);
}
try {
await updateRepository(OFFICIAL_PLUGINS_REPO_URL);
} catch (error) {
console.error("Failed to update official plugins repository", error);
}
await Promise.allSettled(Object.keys(pluginRepositories).map(async repo => {
if (repo !== OFFICIAL_PLUGINS_REPO_URL) {
await updateRepository(repo);
}
}));
await Promise.allSettled(
Object.keys(pluginRepositories).map(async (repo) => {
if (repo !== OFFICIAL_PLUGINS_REPO_URL) {
await updateRepository(repo);
}
}),
);
}
export async function updatePlugins() {
await awaitStorage(pluginRepositories, pluginSettings);
await awaitStorage(pluginRepositories, pluginSettings);
// Register core plugins
const corePlugins = getCorePlugins();
for (const id in corePlugins) {
const {
default: instance,
preenabled
} = corePlugins[id];
// 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
};
// Core plugins are always installed
pluginSettings[id] ??= {
enabled: preenabled ?? true,
};
registeredPlugins.set(id, instance.manifest);
corePluginInstances.set(id, instance);
}
registeredPlugins.set(id, instance.manifest);
corePluginInstances.set(id, instance);
}
await updateAllRepository();
await updateAllRepository();
}
/**
* @internal
* 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() {
await awaitStorage(pluginRepositories, pluginSettings);
export async function initPlugins(
options: { staggerInterval?: number; batchSize?: number } = {},
) {
const { staggerInterval = 100, batchSize = 3 } = options;
// Now, start all enabled plugins...
await Promise.allSettled([...registeredPlugins.keys()].map(async id => {
if (isPluginEnabled(id)) {
await startPlugin(id);
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);
}
}
}

View file

@ -93,6 +93,11 @@ export function patchTabsUI(unpatches: (() => void | boolean)[]) {
-~sections.findIndex((i: any) => i.settings.includes("ACCOUNT")) || 1;
Object.keys(registeredSections).forEach((sect) => {
// Prevent duplicate insertion: on some resumes/re-renders the same
// registeredSections keys may be inserted multiple times. Check if a
// section with the same label already exists before splicing.
const exists = sections.some((s: any) => s && s.label === sect);
if (exists) return;
sections.splice(index++, 0, {
label: sect,
title: sect,

2
src/modules.d.ts vendored
View file

@ -1,5 +1,5 @@
declare module "bunny-build-info" {
const version: "dev1.1.1-1";
const version: "dev1.1.0-2";
}
declare module "*.png" {