Add Updater UI and deferred plugin updates

Introduce Updater settings (VENDETTA_UPDATER_SETTINGS) and add a new
Updater settings page with manual refresh, per-plugin update, and Update
All functionality

Respect fetchPluginsOnStart and per-repo repoAutoFetchOverrides when
fetching plugin repositories to avoid startup network work

Stagger plugin initialization and defer repository updates; change the
scheduled refresh delay to 5 minutes and add periodic GitHub bundle
checks to detect new releases

Remove obsolete team array from General page
This commit is contained in:
kmmiio99o 2025-10-25 11:53:46 +02:00
parent d4d491fa7a
commit 9f6f62093d
6 changed files with 464 additions and 55 deletions

View file

@ -20,6 +20,13 @@ export default function initSettings() {
render: () => import("@core/ui/settings/pages/General"),
useTrailing: () => `(${version})`,
},
{
key: "SHIGGYCORD_UPDATER",
title: () => "Updater",
icon: findAssetId("BellIcon"),
render: () => import("@core/ui/settings/pages/Updater"),
},
{
key: "CORE_PLUGINS",
title: () => Strings.PLUGINS,

View file

@ -29,25 +29,6 @@ export default function General() {
const debugInfo = getDebugInfo();
const navigation = NavigationNative.useNavigation();
// Team members array for easy editing
const TEAM_MEMBERS = [
{
name: "kmmiio99o",
role: "Lead Developer",
avatar: "https://avatars.githubusercontent.com/u/73043890?v=4",
profile: "https://github.com/kmmiio99o",
},
{
name: "Angelw0lf",
role: "Contributor",
avatar: "https://avatars.githubusercontent.com/u/112412567?v=4",
profile: "https://github.com/Angelw0lf",
},
// Add or remove members here
];
// Handler for About the Team is no longer needed (navigation used instead)
return (
<ScrollView
style={{ flex: 1 }}

View file

@ -0,0 +1,262 @@
import React, { useEffect, useState } from "react";
import { useProxy } from "@core/vendetta/storage";
import {
updateAllRepository,
registeredPlugins,
pluginRepositories,
isGreaterVersion,
refreshPlugin,
} from "@lib/addons/plugins";
import { updaterSettings } from "@lib/api/settings";
import { findAssetId } from "@lib/api/assets";
import { getDebugInfo } from "@lib/api/debug";
import {
Stack,
TableRowGroup,
TableSwitchRow,
TableRow,
Text,
Button,
Card,
IconButton,
} from "@metro/common/components";
import { ScrollView, View } from "react-native";
/**
* Updater UI
* - Shows updater toggles (auto update, fetch-on-start)
* - Shows bundle info with refresh button
* - Lists available plugin updates discovered after refresh
* - Provides per-plugin Update button and Update All
*/
type UpdateEntry = {
id: string;
name: string;
current: string;
latest: string;
repo: string;
};
export default function Updater() {
// Bind persisted settings
useProxy(updaterSettings);
const [checking, setChecking] = useState(false);
const [available, setAvailable] = useState<UpdateEntry[]>([]);
const [lastError, setLastError] = useState<string | null>(null);
const fetchPluginsOnStart = updaterSettings.fetchPluginsOnStart ?? true;
const autoEnabled = !!updaterSettings.autoUpdateEnabled;
const lastChecked = updaterSettings.lastChecked ?? null;
const debugInfo = getDebugInfo();
useEffect(() => {
// Optionally, we could auto-refresh after app fully loads if configured,
// but we intentionally keep refresh manual here to avoid background network.
}, []);
const setAutoUpdate = (v: boolean) => {
updaterSettings.autoUpdateEnabled = v;
};
const setFetchOnStart = (v: boolean) => {
updaterSettings.fetchPluginsOnStart = v;
};
const refresh = async () => {
if (checking) return;
setChecking(true);
setLastError(null);
try {
// Update repositories (this function is aware of updaterSettings)
await updateAllRepository();
// mark last checked
updaterSettings.lastChecked = new Date().toISOString();
// Build available updates list:
// Iterate known repositories and compare remote versions with registered plugin versions.
const pending: UpdateEntry[] = [];
const repos = Object.keys(pluginRepositories || {});
for (const repoUrl of repos) {
const repo = pluginRepositories[repoUrl];
if (!repo) continue;
for (const id of Object.keys(repo)) {
if (id === "$meta") continue;
const remoteInfo = repo[id];
if (!remoteInfo || !remoteInfo.version) continue;
const registered = registeredPlugins.get(id);
if (!registered) continue;
const currentVersion = (registered as any).version ?? "0.0.0";
const latestVersion = remoteInfo.version;
if (isGreaterVersion(latestVersion, currentVersion)) {
pending.push({
id,
name: (registered as any).display?.name ?? id,
current: currentVersion,
latest: latestVersion,
repo: repoUrl,
});
}
}
}
setAvailable(pending);
} catch (e) {
console.error("Updater refresh failed", e);
setLastError(String((e as Error)?.message ?? e));
} finally {
setChecking(false);
}
};
const updatePlugin = async (id: string, repo: string) => {
try {
await refreshPlugin(id, repo);
// remove from list
setAvailable((prev) => prev.filter((p) => p.id !== id));
} catch (e) {
console.error(`Failed updating plugin ${id}`, e);
setLastError(String((e as Error)?.message ?? e));
}
};
const updateAll = async () => {
// Iterate copy because we modify available inside loop
const items = [...available];
for (const it of items) {
// sequential to avoid spamming network, but could be parallelized if desired
// ignore errors per plugin and continue
try {
// best-effort
// eslint-disable-next-line no-await-in-loop
await updatePlugin(it.id, it.repo);
} catch {
// swallow - updatePlugin sets lastError
}
}
};
return (
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{ paddingBottom: 38 }}
>
<Stack
style={{ paddingVertical: 24, paddingHorizontal: 12 }}
spacing={24}
>
<TableRowGroup title="Updater">
<TableSwitchRow
label="Auto Update"
subLabel="Automatically install updates when available"
icon={<TableRow.Icon source={findAssetId("CloudUploadIcon")!} />}
value={autoEnabled}
onValueChange={setAutoUpdate}
/>
<TableSwitchRow
label="Fetch external plugins on startup"
subLabel="Reduce startup network activity when disabled"
icon={<TableRow.Icon source={findAssetId("CloudDownloadIcon")!} />}
value={fetchPluginsOnStart}
onValueChange={setFetchOnStart}
/>
<TableRow
label="Last checked"
subLabel={
lastChecked ? new Date(lastChecked).toLocaleString() : "Never"
}
icon={<TableRow.Icon source={findAssetId("ClockIcon")!} />}
/>
<TableRow
label="Bundle"
subLabel={`Version ${debugInfo.bunny.version} — Loader ${debugInfo.bunny.loader.version}`}
icon={
<TableRow.Icon
source={findAssetId("CircleInformationIcon-primary")!}
/>
}
trailing={
<IconButton icon={findAssetId("RetryIcon")!} onPress={refresh} />
}
/>
</TableRowGroup>
<TableRowGroup title="Available Plugin Updates">
{lastError ? (
<Text variant="text-sm/medium" color="text-danger">
Error: {lastError}
</Text>
) : null}
{checking ? (
<Text variant="text-sm/medium" color="text-muted">
Checking for updates...
</Text>
) : available.length === 0 ? (
<Text variant="text-sm/medium" color="text-muted">
No updates available. Press the refresh icon in Bundle to check.
</Text>
) : (
available.map((p) => (
<Card key={p.id} style={{ padding: 10, marginBottom: 10 }}>
<View
style={{
display: "flex",
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
}}
>
<View style={{ flex: 1 }}>
<Text style={{ fontWeight: "bold" }}>{p.name}</Text>
<Text>
Current: {p.current} — Latest: {p.latest}
</Text>
<Text style={{ color: "#666", marginTop: 6, fontSize: 12 }}>
{p.repo}
</Text>
</View>
<View style={{ marginLeft: 10 }}>
<Button
text="Update"
variant="primary"
onPress={() => updatePlugin(p.id, p.repo)}
/>
<Button
text="Skip"
variant="secondary"
style={{ marginTop: 8 }}
onPress={() =>
setAvailable((prev) =>
prev.filter((x) => x.id !== p.id),
)
}
/>
</View>
</View>
</Card>
))
)}
{available.length > 0 ? (
<View style={{ marginTop: 6 }}>
<Button text="Update All" variant="primary" onPress={updateAll} />
</View>
) : null}
</TableRowGroup>
</Stack>
</ScrollView>
);
}

View file

@ -14,6 +14,8 @@ import { patchJsx } from "@lib/api/react/jsx";
import { logger } from "@lib/utils/logger";
import { patchSettings } from "@ui/settings";
import { InteractionManager } from "react-native";
import { updaterSettings } from "@lib/api/settings";
import { getDebugInfo } from "@lib/api/debug";
// Debug toggle helper (temporary runtime fallback). The helper is dynamically
// imported when needed (to avoid bundling it permanently) and removed after use.
@ -57,43 +59,50 @@ export default async () => {
);
// Deferred work: run after interactions to avoid blocking initial paint and navigation.
const runDeferred = () => {
const runDeferred = async () => {
// 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));
// Ensure core plugins and repository metadata are registered/updated BEFORE
// attempting to start ShiggyCord (Bunny) plugins. This makes corePluginInstances
// available to `initPlugins()` so built-in core plugins are handled correctly.
updatePlugins()
.catch((e) => logger.log("updatePlugins failed (deferred):", e))
.finally(async () => {
// Start ShiggyCord (Bunny) plugins (reads storage and may compile/instantiate plugins).
initPlugins();
// Start ShiggyCord (Bunny) plugins now without forcing repository updates.
// Plugin repository fetching is deferred so the app can finish launching first.
// Stagger plugin startup to reduce CPU/memory spikes: use smaller batches and a small interval.
// This keeps the UI responsive while plugins initialize in the background.
initPlugins({ staggerInterval: 500, batchSize: 2 });
// Attempt a lightweight recovery toggle if some core plugins failed to start.
try {
// Dynamically import the helper (if present) but suppress verbose errors.
const mod = await import("@core/debug/toggleCorePlugins").catch(
() => null,
);
if (mod?.default) {
// Run helper with minimal noise; ignore failures.
mod.default({ offDuration: 1500 }).catch(() => {});
}
// Attempt a lightweight recovery toggle if some core plugins failed to start.
try {
// Dynamically import the helper (if present) but suppress verbose errors.
const mod = await import("@core/debug/toggleCorePlugins").catch(
() => null,
);
if (mod?.default) {
// Run helper with minimal noise; ignore failures.
mod.default({ offDuration: 1500 }).catch(() => {});
}
// Try to remove the helper source file (best-effort, ignore failures).
await import("@lib/api/native/fs")
.then((fs) => fs.removeFile("src/core/debug/toggleCorePlugins.ts"))
.catch(() => {});
} catch {
// suppressed
}
// Try to remove the helper source file (best-effort, ignore failures).
await import("@lib/api/native/fs")
.then((fs) => fs.removeFile("src/core/debug/toggleCorePlugins.ts"))
.catch(() => {});
} catch {
// suppressed
}
// Update fonts in background
updateFonts().catch((e) => logger.log("updateFonts failed:", e));
});
// Update fonts in background
updateFonts().catch((e) => logger.log("updateFonts failed:", e));
// Schedule plugin repository update after a delay (5 minutes) so update work
// does not impact initial launch performance.
setTimeout(
() => {
updatePlugins().catch((e) =>
logger.log("updatePlugins failed (deferred 5min):", e),
);
},
5 * 60 * 1000,
);
// Note: we intentionally moved the call to `updatePlugins()` above so core
// plugins are registered prior to calling `initPlugins()`.
@ -110,17 +119,59 @@ export default async () => {
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.
// Schedule a delayed plugin repository refresh (5 minutes) if the earlier deferred schedule didn't run.
// This ensures we refresh plugin metadata after the app is fully launched but avoid startup contention.
setTimeout(
() => {
updatePlugins().catch((e) =>
logger.log("Scheduled updatePlugins (3min) failed:", e),
logger.log("Scheduled updatePlugins (5min) failed:", e),
);
},
3 * 60 * 1000,
5 * 60 * 1000,
);
// Periodic bundle check: every 3 hours, check GitHub releases (ignore prereleases).
// We use the GitHub Releases API to find the latest non-prerelease release and compare its tag.
const checkBundle = async () => {
try {
const debugInfo = getDebugInfo();
const releasesRes = await fetch(
"https://api.github.com/repos/kmmiio99o/ShiggyCord/releases",
{ cache: "no-store" },
);
if (!releasesRes.ok) return;
const releases = await releasesRes.json();
if (!Array.isArray(releases) || releases.length === 0) return;
// Find first non-prerelease, non-draft release
const stable = releases.find((r: any) => !r.prerelease && !r.draft);
if (!stable) return;
const latestTag = stable.tag_name ?? stable.name ?? null;
const latestUrl = stable.html_url ?? null;
const installed = debugInfo?.bunny?.version ?? null;
updaterSettings.lastBundleChecked = new Date().toISOString();
updaterSettings.lastBundleVersion = latestTag;
updaterSettings.bundleLatestTag = latestTag;
updaterSettings.bundleLatestUrl = latestUrl ?? null;
updaterSettings.bundleAvailable =
latestTag != null &&
installed != null &&
String(latestTag).replace(/^v/, "") !==
String(installed).replace(/^v/, "");
} catch (err) {
logger.log("Bundle check failed:", err);
}
};
// Run initial bundle check after a short delay (1 minute) to avoid doing network work
// during the critical UI startup path, then run every 3 hours.
setTimeout(() => {
checkBundle().catch(() => {});
}, 60 * 1000);
setInterval(checkBundle, 3 * 60 * 60 * 1000);
// Final ready log for basic UI availability.
logger.log("ShiggyCord is ready (UI available).");
};

View file

@ -11,6 +11,7 @@ import {
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";
@ -528,16 +529,32 @@ export function getBisectBatches(maxPerStep = 50): string[][] {
}
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(
Object.keys(pluginRepositories).map(async (repo) => {
if (repo !== OFFICIAL_PLUGINS_REPO_URL) {
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);
}
}),
);
@ -560,7 +577,36 @@ export async function updatePlugins() {
corePluginInstances.set(id, instance);
}
await updateAllRepository();
// 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);
}
}),
);
}
}
/**

View file

@ -25,6 +25,56 @@ export interface LoaderConfig {
loadReactDevTools: boolean;
}
/**
* New: Updater settings persisted in MMKV.
* Fields:
* - autoUpdateEnabled: whether updates are applied automatically
* - checkOnStart: whether to check for updates on app start
* - updateChannel: optional update channel (stable/beta/alpha)
* - lastChecked: ISO timestamp of last update check
*
* Plugin-related additions:
* - fetchPluginsOnStart: when true, plugins (external repositories) are fetched at startup;
* when false, plugin repository fetching is deferred and controlled from the Updater UI.
* - repoAutoFetchOverrides: per-repository overrides for auto-fetch behavior
* (keyed by repository URL, boolean value overrides fetchPluginsOnStart).
*/
export interface UpdaterSettings {
// Whether updates (bundle-level auto-updates) should be applied automatically
autoUpdateEnabled: boolean;
// Whether to perform an update check on app start
checkOnStart: boolean;
// Optional channel preference for updates
updateChannel?: "stable" | "beta" | "alpha";
// ISO timestamp of the last time the updater checked (plugins + bundle)
lastChecked?: string | null;
// -------------------------
// Plugin-related settings
// -------------------------
// Whether external plugin repositories should be fetched at startup
fetchPluginsOnStart?: boolean;
// Per-repo override for auto-fetch behavior (repoUrl => boolean)
repoAutoFetchOverrides?: Record<string, boolean>;
// -------------------------
// Bundle update settings
// -------------------------
// How often (in hours) to check the project's releases for bundle updates.
// Default should be treated as 3 if unset.
bundleCheckIntervalHours?: number;
// Releases URL (used for checking the bundle). Defaults to:
// https://github.com/kmmiio99o/ShiggyCord/releases/
bundleReleasesUrl?: string;
// Whether to ignore prerelease releases when checking bundle updates.
// Defaults to true.
bundleIgnorePrereleases?: boolean;
// ISO timestamp of the last bundle check
lastBundleChecked?: string | null;
// Last known bundle version discovered by the updater (if any)
lastBundleVersion?: string | null;
}
export const settings = wrapSync(
createStorage<Settings>(createMMKVBackend("VENDETTA_SETTINGS")),
);
@ -36,6 +86,18 @@ export const loaderConfig = wrapSync(
enabled: false,
url: "http://localhost:4040/shiggycord.js",
},
loadReactDevTools: false,
}),
),
);
/**
* Updater settings storage.
* Stored under MMKV key: VENDETTA_UPDATER_SETTINGS
* Defaults should be handled by UI code when reading values if necessary.
*/
export const updaterSettings = wrapSync(
createStorage<UpdaterSettings>(
createMMKVBackend("VENDETTA_UPDATER_SETTINGS"),
),
);