diff --git a/scripts/build.mjs b/scripts/build.mjs index dad5e02..c066106 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -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"`, }, }, }, diff --git a/src/core/fixes.ts b/src/core/fixes.ts index 1046952..1791066 100644 --- a/src/core/fixes.ts +++ b/src/core/fixes.ts @@ -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); }; diff --git a/src/core/plugins/fixembed/index.ts b/src/core/plugins/fixembed/index.ts index b8d8464..b958111 100644 --- a/src/core/plugins/fixembed/index.ts +++ b/src/core/plugins/fixembed/index.ts @@ -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({ - 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); diff --git a/src/core/plugins/shiggyhelper/index.tsx b/src/core/plugins/shiggyhelper/index.tsx index f9fbe13..d50209a 100644 --- a/src/core/plugins/shiggyhelper/index.tsx +++ b/src/core/plugins/shiggyhelper/index.tsx @@ -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, - }), + ), ), ]); }, diff --git a/src/core/ui/settings/pages/General/index.tsx b/src/core/ui/settings/pages/General/index.tsx index 75d29d0..681e720 100644 --- a/src/core/ui/settings/pages/General/index.tsx +++ b/src/core/ui/settings/pages/General/index.tsx @@ -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); diff --git a/src/core/ui/settings/pages/Plugins/index.tsx b/src/core/ui/settings/pages/Plugins/index.tsx index 7d655c7..7634947 100644 --- a/src/core/ui/settings/pages/Plugins/index.tsx +++ b/src/core/ui/settings/pages/Plugins/index.tsx @@ -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(); + (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 ( 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} diff --git a/src/index.ts b/src/index.ts index 19d6116..ae8b338 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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)."); }; diff --git a/src/lib/addons/plugins/index.ts b/src/lib/addons/plugins/index.ts index 2d4e9c1..d6b8e92 100644 --- a/src/lib/addons/plugins/index.ts +++ b/src/lib/addons/plugins/index.ts @@ -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(); export const registeredPlugins = new Map(); export const pluginInstances = new Map(); -export const apiObjects = new Map>(); +export const apiObjects = new Map< + string, + ReturnType +>(); -export const pluginRepositories = createStorage("plugins/repositories.json"); -export const pluginSettings = createStorage("plugins/settings.json"); +export const pluginRepositories = createStorage( + "plugins/repositories.json", +); +export const pluginSettings = createStorage( + "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(condition: T, id: string, attempt: string): asserts condition { - if (!condition) throw new Error(`[${id}] Attempted to ${attempt}`); +function assert( + condition: T, + id: string, + attempt: string, +): asserts condition { + if (!condition) throw new Error(`[${id}] Attempted to ${attempt}`); } /** @@ -40,34 +61,38 @@ function assert(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 | null { - const instance = pluginInstances.get(id); - if (!instance) return null; +export function getPluginSettingsComponent( + id: string, +): React.ComponentType | 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( + `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(`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[]; + const promQueues = [] as Promise[]; - 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((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); + } + } } diff --git a/src/lib/ui/settings/patches/tabs.tsx b/src/lib/ui/settings/patches/tabs.tsx index 8fce28b..015a88b 100644 --- a/src/lib/ui/settings/patches/tabs.tsx +++ b/src/lib/ui/settings/patches/tabs.tsx @@ -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, diff --git a/src/modules.d.ts b/src/modules.d.ts index 6e2c57b..2bae64b 100644 --- a/src/modules.d.ts +++ b/src/modules.d.ts @@ -1,5 +1,5 @@ declare module "bunny-build-info" { - const version: "dev1.1.1-1"; + const version: "dev1.1.0-2"; } declare module "*.png" {