feat(ui): allow adding repository

and fix some of the api
This commit is contained in:
pylixonly 2024-10-23 16:43:02 +08:00
parent 6836d23e78
commit b5611d3cf1
4 changed files with 148 additions and 98 deletions

View file

@ -1,44 +1,24 @@
import { installPlugin, isPluginInstalled, uninstallPlugin } from "@lib/addons/plugins";
import { BunnyPluginManifest } from "@lib/addons/plugins/types";
import { deleteRepository, installPlugin, isCorePlugin, isPluginInstalled, pluginRepositories, registeredPlugins, uninstallPlugin, updateAllRepository, updateRepository } from "@lib/addons/plugins";
import { BunnyPluginManifestInternal } from "@lib/addons/plugins/types";
import { findAssetId } from "@lib/api/assets";
import { showSheet } from "@lib/ui/sheets";
import { dismissAlert, openAlert } from "@lib/ui/alerts";
import { AlertActionButton } from "@lib/ui/components/wrappers";
import { hideSheet, showSheet } from "@lib/ui/sheets";
import { showToast } from "@lib/ui/toasts";
import { safeFetch } from "@lib/utils";
import { OFFICIAL_PLUGINS_REPO_URL } from "@lib/utils/constants";
import { NavigationNative } from "@metro/common";
import { ActionSheet, Button, Card, FlashList, IconButton, Stack, TableRow, TableRowGroup, Text } from "@metro/common/components";
import isValidHttpUrl from "@lib/utils/isValidHttpUrl";
import { clipboard, NavigationNative } from "@metro/common";
import { ActionSheet, AlertActions, AlertModal, Button, Card, FlashList, IconButton, Stack, TableRow, TableRowGroup, Text, TextInput } from "@metro/common/components";
import { QueryClient, QueryClientProvider, useMutation, useQuery } from "@tanstack/react-query";
import { chunk } from "es-toolkit";
import { useEffect, useState } from "react";
import { View } from "react-native";
const queryClient = new QueryClient();
async function arrayFromAsync<T>(iterableOrArrayLike: AsyncIterable<T>): Promise<T[]> {
const arr: T[] = [];
for await (const element of iterableOrArrayLike) arr.push(element);
return arr;
}
async function fetchManifest(repoURL: string, id: string) {
const url = new URL(`builds/${id}/manifest.json`, repoURL);
const data = await safeFetch(url).then(d => d.json());
queryClient.setQueryData(["plugin-manifest-dist", { id }], data);
return data as BunnyPluginManifest;
}
async function* getManifests(repoUrl: string) {
const rawResponse = await safeFetch(repoUrl);
const pluginIds = Object.keys(await rawResponse.json());
for (const idChunks of chunk(pluginIds, 5)) {
const manifests = idChunks.map(id => fetchManifest(OFFICIAL_PLUGINS_REPO_URL, id));
for (const manifest of manifests) {
yield await manifest;
}
}
async function getManifests() {
await updateAllRepository();
const plugins = [...registeredPlugins.values()];
return plugins.filter(p => !isCorePlugin(p.id));
}
function InstallButton(props: { id: string; }) {
@ -69,7 +49,15 @@ function TrailingButtons(props: { id: string; }) {
return <Stack spacing={8} direction="horizontal">
<IconButton
size="sm"
onPress={() => { }}
onPress={() => {
showSheet("plugin-info", () => {
return <ActionSheet>
<TableRowGroup title="Plugin Info">
<TableRow label="ID" subLabel={props.id} />
</TableRowGroup>
</ActionSheet>;
});
}}
variant="secondary"
icon={findAssetId("CircleInformationIcon")}
/>
@ -77,38 +65,29 @@ function TrailingButtons(props: { id: string; }) {
</Stack>;
}
function PluginCard(props: { repoUrl: string, id: string, manifest: BunnyPluginManifest; }) {
const { isPending, error, data: plugin } = useQuery({
queryKey: ["plugin-manifest-dist", { id: props.id }],
queryFn: () => fetchManifest(props.repoUrl, props.id)
});
function PluginCard(props: { manifest: BunnyPluginManifestInternal; }) {
const { display, version } = props.manifest;
return (
<Card>
{!plugin && <View style={{ justifyContent: "center", alignItems: "center" }}>
<Text color="text-muted" variant="heading-lg/semibold">
{isPending && "Loading..."}
{error && `An error has occured while fetching plugin: ${error.message}`}
</Text>
</View>}
{plugin && <Stack spacing={16}>
<Stack spacing={16}>
<View style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "center" }}>
<View style={{ flexShrink: 1 }}>
<Text numberOfLines={1} variant="heading-lg/semibold">
{plugin.display.name}
{display.name}
</Text>
<Text variant="text-md/semibold" color="text-muted">
by {plugin.display.authors?.map(a => typeof a === "string" ? a : a.name).join(", ") ?? "Unknown"}
by {display.authors?.map(a => a.name).join(", ") || "Unknown"} ({version})
</Text>
</View>
<View>
<TrailingButtons id={props.id} />
<TrailingButtons id={props.manifest.id} />
</View>
</View>
<Text variant="text-md/medium">
{plugin.display.description}
{display.description}
</Text>
</Stack>}
</Stack>
</Card>
);
}
@ -123,37 +102,7 @@ function BrowserPage() {
variant="secondary"
icon={findAssetId("PlusSmallIcon")}
onPress={() => {
showSheet("plugin-browser-options", () => {
return <ActionSheet>
<TableRowGroup title="Repositories">
<TableRow
label="Bunny Repository"
subLabel={OFFICIAL_PLUGINS_REPO_URL}
trailing={
<Stack direction="horizontal">
<IconButton
size="sm"
variant="secondary"
icon={findAssetId("LinkIcon")}
onPress={() => { }}
/>
<IconButton
size="sm"
variant="destructive"
disabled={true}
icon={findAssetId("TrashIcon")}
onPress={() => { }}
/>
</Stack>}
/>
<TableRow
label="Add Repository..."
icon={<TableRow.Icon source={findAssetId("PlusMediumIcon")} />}
onPress={() => { }}
/>
</TableRowGroup>
</ActionSheet>;
});
showSheet("plugin-browser-options", PluginBrowserOptions);
}}
/>
});
@ -161,7 +110,7 @@ function BrowserPage() {
const { data, error, isPending, refetch } = useQuery({
queryKey: ["plugins-repo-fetch"],
queryFn: () => arrayFromAsync(getManifests(OFFICIAL_PLUGINS_REPO_URL))
queryFn: () => getManifests()
});
if (error) {
@ -196,12 +145,108 @@ function BrowserPage() {
contentContainerStyle={{ paddingBottom: 90, paddingHorizontal: 5 }}
renderItem={({ item: manifest }: any) => (
<View style={{ paddingVertical: 6, paddingHorizontal: 8 }}>
<PluginCard repoUrl={OFFICIAL_PLUGINS_REPO_URL} id={manifest.id} manifest={manifest} />
<PluginCard manifest={manifest} />
</View>
)}
/>;
}
function AddRepositoryAlert() {
const [value, setValue] = useState("");
return <AlertModal
title="Add Repository"
content="Enter the URL of the repository you want to add."
extraContent={<TextInput
value={value}
onChange={setValue}
placeholder="https://example.com/repo.json" />}
actions={<AlertActions>
<AlertActionButton
text="Add"
variant="primary"
disabled={!isValidHttpUrl(value)}
onPress={async () => {
try {
await updateRepository(value);
showToast("Added repository!", findAssetId("Check"));
} catch (e) {
showToast("Failed to add repository!", findAssetId("Small"));
} finally {
dismissAlert("bunny-add-plugin-repository");
showSheet("plugin-browser-options", PluginBrowserOptions);
}
}} />
</AlertActions>} />;
}
function PluginBrowserOptions() {
return <ActionSheet>
<TableRowGroup title="Repositories">
{Object.keys(pluginRepositories).map(url => {
return <RepositoryRow key={url} url={url} />;
})}
<TableRow
label="Add Repository..."
icon={<TableRow.Icon source={findAssetId("PlusMediumIcon")} />}
onPress={() => {
openAlert("bunny-add-plugin-repository", <AddRepositoryAlert />);
hideSheet("plugin-browser-options");
}} />
</TableRowGroup>
</ActionSheet>;
}
function RepositoryRow(props: { url: string; }) {
const repo = pluginRepositories[props.url];
const isOfficial = props.url === OFFICIAL_PLUGINS_REPO_URL;
return (
<TableRow
label={isOfficial ? "Bunny's Repository" : (repo.$meta?.name ?? "Unknown")}
subLabel={props.url}
trailing={(
<Stack direction="horizontal">
<IconButton
size="sm"
variant="secondary"
icon={findAssetId("LinkIcon")}
onPress={() => {
clipboard.setString(props.url);
showToast.showCopyToClipboard();
}}
/>
<IconButton
size="sm"
variant="destructive"
disabled={isOfficial}
icon={findAssetId("TrashIcon")}
onPress={() => {
openAlert("bunny-remove-repository", <AlertModal
title="Remove Repository"
content="Are you sure you want to remove this repository?"
extraContent={<Card>
<Text variant="text-md/normal">{props.url}</Text>
</Card>}
actions={<AlertActions>
<AlertActionButton
text="Remove"
variant="destructive"
onPress={async () => {
await deleteRepository(props.url);
showToast("Removed repository!", findAssetId("Trash"));
dismissAlert("bunny-remove-repository");
}}
/>
</AlertActions>}
/>);
}}
/>
</Stack>
)} />
);
}
export default function PluginBrowser() {
return (
<QueryClientProvider client={queryClient}>

View file

@ -1,6 +1,6 @@
import { getCorePlugins } from "@core/plugins";
import { readFile, removeFile, writeFile } from "@lib/api/native/fs";
import { awaitStorage, createStorage, getPreloadedStorage, preloadStorageIfExists, 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";
@ -36,9 +36,10 @@ function assert<T>(condition: T, id: string, attempt: string): asserts condition
/**
* Checks if a version is newer than the other. However, this comes with an additional logic,
* where if the version are equal, one with prerelease "tag" will be considered "newer"
* @internal
* @returns Whether the version is newer
*/
function newerThan(v1: string, v2: string) {
export function isGreaterVersion(v1: string, v2: string) {
if (semver.gt(v1, v2)) return true;
const coerced = semver.coerce(v1);
if (coerced == null) return false;
@ -152,13 +153,13 @@ export async function updateRepository(repoUrl: string) {
}
await Promise.all(Object.keys(repo).map(async pluginId => {
if (!storedRepo || !storedRepo[pluginId] || repo[pluginId].alwaysFetch || newerThan(repo[pluginId].version, storedRepo[pluginId].version)) {
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 === undefined) { // File does not exist, so do refetch and stuff
if (!manifest) { // File does not exist, so do refetch and stuff
await updateAndWritePlugin(repoUrl, pluginId, Boolean(storedRepo && pluginSettings[pluginId]));
}
}
@ -172,7 +173,7 @@ export async function updateRepository(repoUrl: string) {
const existing = registeredPlugins.get(id);
// Skip if this version isn't any higher
if (existing && !newerThan(manifest.version, existing.version)) {
if (existing && !isGreaterVersion(manifest.version, existing.version)) {
continue;
}
@ -200,11 +201,13 @@ export async function deleteRepository(repoUrl: string) {
}
// Deregister all plugins under this repository
promQueues.push(purgeStorage(`plugins/manifests/${id}.json`));
registeredPlugins.delete(id);
}
delete pluginRepositories[repoUrl];
await Promise.all(promQueues);
updateAllRepository();
}
/**
@ -341,7 +344,7 @@ export function stopPlugin(id: string) {
pluginInstances.delete(id);
}
async function updateAllRepository() {
export async function updateAllRepository() {
try {
await updateRepository(OFFICIAL_PLUGINS_REPO_URL);
} catch (error) {

View file

@ -2,14 +2,16 @@ import { createStorage } from "@core/vendetta/storage";
import { BunnyManifest } from "@lib/addons/types";
import { Logger } from "@lib/utils/logger";
export interface PluginRepo {
[id: string]: {
version: string;
// For plugin developing convenience, plugins with this on will always get fetched
alwaysFetch?: boolean;
export type PluginRepo = Record<string, {
version: string;
// For plugin developing convenience, plugins with this on will always get fetched
alwaysFetch?: boolean;
}> & {
$meta: {
name: string;
description: string;
};
}
};
export interface PluginRepoStorage {
[repoUrl: string]: PluginRepo;

View file

@ -169,10 +169,10 @@ export async function preloadStorageIfExists(path: string): Promise<boolean> {
const backend = createFileBackend(path);
if (await backend.exists()) {
_loadedStorage[path] = await backend.get();
return false;
return true;
}
return true;
return false;
}
export async function purgeStorage(path: string) {