feat(ui): allow adding repository
and fix some of the api
This commit is contained in:
parent
6836d23e78
commit
b5611d3cf1
4 changed files with 148 additions and 98 deletions
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue