Improve updater robustness and add bundle check

Bump bunny-build-info to 1.1.7.1.

Add cache-busting to repo fetches and try multiple manifest/script
paths. Fall back to a single-plugin manifest.json for hosts that don't
expose repo.json. Improve updateAndWritePlugin/updateRepository error
handling and storage updates.

UI: merge auto-update toggles, remove fetch-on-start switch, add "Fetch
Now" button and toasts for progress/results. Persist bundle metadata
(tag/url/available) in settings and implement bundle check.
This commit is contained in:
kmmiio99o 2025-10-26 01:05:07 +02:00
parent 3aee46ff4b
commit 732f42e570
4 changed files with 293 additions and 50 deletions

View file

@ -77,7 +77,7 @@ const config = {
constModules: {
globals: {
"bunny-build-info": {
version: `"1.1.7"`,
version: `"1.1.7.1"`,
},
},
},

View file

@ -1,15 +1,19 @@
import React, { useEffect, useState } from "react";
import { useProxy } from "@core/vendetta/storage";
import {
updateRepository,
updateAllRepository,
updateAndWritePlugin,
registeredPlugins,
pluginRepositories,
pluginSettings,
isGreaterVersion,
refreshPlugin,
} from "@lib/addons/plugins";
import { updaterSettings } from "@lib/api/settings";
import { awaitStorage } from "@lib/api/storage";
import { findAssetId } from "@lib/api/assets";
import { getDebugInfo } from "@lib/api/debug";
import { showToast } from "@lib/ui/toasts";
import {
Stack,
TableRowGroup,
@ -46,12 +50,10 @@ export default function Updater() {
const [available, setAvailable] = useState<UpdateEntry[]>([]);
const [lastError, setLastError] = useState<string | null>(null);
const fetchPluginsOnStart = updaterSettings.fetchPluginsOnStart ?? true;
// Merge both toggles into one: autoEnabled
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.
@ -59,9 +61,6 @@ export default function Updater() {
const setAutoUpdate = (v: boolean) => {
updaterSettings.autoUpdateEnabled = v;
};
const setFetchOnStart = (v: boolean) => {
updaterSettings.fetchPluginsOnStart = v;
};
@ -69,20 +68,79 @@ export default function Updater() {
if (checking) return;
setChecking(true);
setLastError(null);
// Provide immediate user feedback
try {
// Update repositories (this function is aware of updaterSettings)
await updateAllRepository();
showToast("Checking for updates...");
// Ensure storages are loaded so pluginRepositories/pluginSettings are populated
try {
await awaitStorage(pluginRepositories, pluginSettings);
} catch (e) {
console.warn("Updater: awaitStorage failed", e);
}
// For each registered repository, attempt to refresh its metadata and then
// fetch each plugin's manifest+script so it becomes available in the updater
const repos = Object.keys(pluginRepositories || {});
console.debug("Updater: manual fetch for repos:", repos);
for (const repoUrl of repos) {
try {
// Attempt to refresh repo.json so we have an up-to-date list of plugins
try {
await updateRepository(repoUrl);
} catch (repoErr) {
console.warn(
"Updater: updateRepository failed for",
repoUrl,
repoErr,
);
// continue - we may still have entries in pluginRepositories to iterate
}
const repo = pluginRepositories[repoUrl];
if (!repo) continue;
// For each plugin declared in the repository, try to fetch its manifest and script.
for (const pluginId of Object.keys(repo)) {
if (pluginId === "$meta") continue;
try {
const manifest = await updateAndWritePlugin(
repoUrl,
pluginId,
true,
);
if (manifest) {
// Register the fetched manifest in-memory so the Updater list can use it
registeredPlugins.set(pluginId, manifest as any);
console.debug(
`Updater: registered plugin ${pluginId} from ${repoUrl}`,
);
}
} catch (fetchErr) {
console.warn(
`Updater: failed to fetch plugin ${pluginId} from ${repoUrl}`,
fetchErr,
);
}
}
} catch (eRepo) {
console.error(
"Updater: unexpected error iterating repo",
repoUrl,
eRepo,
);
}
}
// mark last checked
updaterSettings.lastChecked = new Date().toISOString();
// Build available updates list:
// Iterate known repositories and compare remote versions with registered plugin versions.
// Build available updates list by comparing registered plugin versions to repository metadata
const pending: UpdateEntry[] = [];
const repos = Object.keys(pluginRepositories || {});
for (const repoUrl of repos) {
const repos2 = Object.keys(pluginRepositories || {});
for (const repoUrl of repos2) {
const repo = pluginRepositories[repoUrl];
if (!repo) continue;
@ -110,14 +168,104 @@ export default function Updater() {
}
setAvailable(pending);
// Inform user of result (we do not auto-install anything; we only populate the Updater)
if (pending.length === 0) {
showToast("No updates available");
} else {
showToast(
`Found ${pending.length} update${pending.length > 1 ? "s" : ""}`,
);
}
} catch (e) {
console.error("Updater refresh failed", e);
setLastError(String((e as Error)?.message ?? e));
const msg = String((e as Error)?.message ?? e);
setLastError(msg);
showToast(`Failed to check for updates: ${msg}`);
} finally {
setChecking(false);
}
};
const checkBundleNow = async () => {
try {
showToast("Checking bundle...");
const customEnabled = Boolean(loaderConfig?.customLoadUrl?.enabled);
const customUrl = loaderConfig?.customLoadUrl?.url ?? null;
const extractVersion = (txt: string) => {
const m =
txt.match(/\bversion\s*[:=]\s*['"`]v?([^'"`\s;]+)['"`]/i) ||
txt.match(/export\s+const\s+version\s*=\s*['"`]([^'"`\n]+)['"`]/i) ||
txt.match(/\bVERSION\b\s*[:=]\s*['"`]([^'"`\n]+)['"`]/i) ||
txt.match(/@version\s+v?([0-9]+\.[0-9]+\.[0-9][^\s]*)/i);
return m ? m[1] : null;
};
let latestTag: string | null = null;
let latestUrl: string | null = null;
if (customEnabled && customUrl) {
try {
const r = await fetch(customUrl, { cache: "no-store" });
if (r.ok) {
const txt = await r.text();
const v = extractVersion(txt);
if (v) {
latestTag = v;
latestUrl = customUrl;
}
}
} catch (e) {
console.warn("checkBundleNow custom fetch failed", e);
}
}
if (!latestTag) {
try {
const bundleDownload =
"https://github.com/kmmiio99o/ShiggyCord/releases/latest/download/shiggycord.js";
const r2 = await fetch(bundleDownload, { cache: "no-store" });
if (r2.ok) {
const txt2 = await r2.text();
const v2 = extractVersion(txt2);
if (v2) {
latestTag = v2;
latestUrl = bundleDownload;
}
}
} catch (e) {
console.warn("checkBundleNow default download failed", e);
}
}
const installed = getDebugInfo()?.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/, "");
if (latestTag) {
if (updaterSettings.bundleAvailable) {
showToast(`Bundle update available: ${latestTag}`);
} else {
showToast(`Bundle up-to-date: ${latestTag}`);
}
} else {
showToast("Bundle check completed: no version found");
}
} catch (e) {
console.error("checkBundleNow failed", e);
showToast("Bundle check failed");
}
};
const updatePlugin = async (id: string, repo: string) => {
try {
await refreshPlugin(id, repo);
@ -156,21 +304,13 @@ export default function Updater() {
>
<TableRowGroup title="Updater">
<TableSwitchRow
label="Auto Update"
subLabel="Automatically install updates when available"
label="Auto Update Plugins"
subLabel="Automatically check and install plugin updates at startup"
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={
@ -178,21 +318,18 @@ export default function Updater() {
}
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>
<View style={{ marginTop: 4, marginBottom: 8 }}>
<Button
text="Fetch Now"
variant="primary"
onPress={refresh}
disabled={checking}
style={{ width: "100%", height: 48, fontSize: 18 }}
/>
</View>
<TableRowGroup title="Available Plugin Updates">
{lastError ? (
<Text variant="text-sm/medium" color="text-danger">
@ -206,7 +343,7 @@ export default function Updater() {
</Text>
) : available.length === 0 ? (
<Text variant="text-sm/medium" color="text-muted">
No updates available. Press the refresh icon in Bundle to check.
No updates available. Press the refresh icon to check.
</Text>
) : (
available.map((p) => (

View file

@ -87,8 +87,17 @@ export const pluginSettings = createStorage<t.PluginSettingsStorage>(
"plugins/settings.json",
);
const _fetch = (repoUrl: string, path: string) =>
safeFetch(new URL(path, repoUrl), { cache: "no-store" });
const _fetch = (repoUrl: string, path: string) => {
try {
const u = new URL(path, repoUrl);
// Append cache-busting query parameter so manual refresh triggers network requests
u.searchParams.set("_", String(Date.now()));
return safeFetch(u, { cache: "no-store" });
} catch (e) {
// Fallback to original behavior if URL construction fails for any reason
return safeFetch(new URL(path, repoUrl), { cache: "no-store" });
}
};
const fetchJS = (repoUrl: string, path: string) =>
_fetch(repoUrl, path).then((r) => r.text());
const fetchJSON = (repoUrl: string, path: string) =>
@ -154,10 +163,30 @@ export async function updateAndWritePlugin(
id: string,
fetchScript: boolean,
) {
const manifest: t.BunnyPluginManifestInternal = await fetchJSON(
repoUrl,
// Try multiple manifest/script paths and provide robust logging so we can trace
// exactly which network requests are performed. Primary path is `builds/...`,
// fallback paths include `id/manifest.json` and `id/index.js` (some repos expose those).
const manifestCandidates = [
`builds/${id}/manifest.json`,
);
`${id}/manifest.json`,
];
let manifest: t.BunnyPluginManifestInternal | undefined;
let lastManifestErr: any = null;
for (const candidate of manifestCandidates) {
const url = new URL(candidate, repoUrl).toString();
try {
manifest = await fetchJSON(repoUrl, candidate);
break;
} catch (e) {
lastManifestErr = e;
}
}
if (!manifest) {
throw lastManifestErr ?? new Error(`Failed to fetch manifest for ${id}`);
}
// @ts-expect-error - Setting a readonly property
manifest.parentRepository = repoUrl;
@ -166,11 +195,36 @@ export async function updateAndWritePlugin(
// @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 scriptCandidates = [`builds/${id}/index.js`, `${id}/index.js`];
let js: string | undefined;
let lastScriptErr: any = null;
for (const candidate of scriptCandidates) {
const url = new URL(candidate, repoUrl).toString();
try {
js = await fetchJS(repoUrl, candidate);
break;
} catch (e) {
lastScriptErr = e;
}
}
if (!js) {
throw lastScriptErr ?? new Error(`Failed to fetch script for ${id}`);
}
try {
await writeFile(manifest.jsPath, js);
} catch (writeErr) {
throw writeErr;
}
}
await updateStorage(`plugins/manifests/${id}.json`, manifest);
try {
await updateStorage(`plugins/manifests/${id}.json`, manifest);
} catch (e) {
throw e;
}
if (registeredPlugins.has(id)) {
const existingManifest = registeredPlugins.get(id)!;
@ -214,11 +268,55 @@ 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];
// Attempt to fetch a repository descriptor (repo.json). If that fails,
// try to treat the provided URL as a single-plugin host (manifest.json at root)
// and synthesize a 1-entry repo mapping. This allows local/dev servers which
// serve a single plugin at the host root to be consumed by the updater.
let repo: t.PluginRepo;
let storedRepo = pluginRepositories[repoUrl];
let updated = false;
try {
repo = await fetchJSON(repoUrl, "repo.json");
} catch (repoErr) {
// repo.json not found — try fallback candidate manifest at the repo root.
try {
const manifest = (await fetchJSON(repoUrl, "manifest.json")) as any;
// Infer plugin id:
// Prefer an explicit id property if present; otherwise use name; otherwise
// fall back to a path-derived token (path or hostname).
const inferredId =
manifest?.id ??
manifest?.name ??
(() => {
try {
const u = new URL(repoUrl);
const p = u.pathname.replace(/^\/|\/$/g, "");
return p || u.hostname;
} catch {
return "local-plugin";
}
})();
// Build a minimal repo mapping containing the single manifest entry.
repo = {
[inferredId]: manifest,
// Keep a $meta marker to be consistent with multi-plugin repos (optional)
$meta: { source: repoUrl },
} as any;
// Persist mapping so other code can read pluginRepositories[repoUrl]
pluginRepositories[repoUrl] = repo;
storedRepo = pluginRepositories[repoUrl];
updated = true;
} catch (manifestErr) {
// If both repo.json and manifest.json attempts fail, rethrow the original repo error
// so callers can handle/report the problem.
throw repoErr;
}
}
// This repository never existed, update it!
if (!storedRepo) {
for (const id in repo) {

View file

@ -73,6 +73,14 @@ export interface UpdaterSettings {
lastBundleChecked?: string | null;
// Last known bundle version discovered by the updater (if any)
lastBundleVersion?: string | null;
// Latest discovered bundle tag (extracted from custom URL content or GitHub).
// This represents the most recent tag/identifier the updater detected.
bundleLatestTag?: string | null;
// The URL used to determine the latest bundle (download URL or release page).
bundleLatestUrl?: string | null;
// Whether a newer bundle than the installed one is available.
bundleAvailable?: boolean;
}
export const settings = wrapSync(