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:
parent
3aee46ff4b
commit
732f42e570
4 changed files with 293 additions and 50 deletions
|
|
@ -77,7 +77,7 @@ const config = {
|
|||
constModules: {
|
||||
globals: {
|
||||
"bunny-build-info": {
|
||||
version: `"1.1.7"`,
|
||||
version: `"1.1.7.1"`,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue