[Plugins] Proper persistence!

This commit is contained in:
Beef 2023-01-07 23:05:14 +00:00
parent 6de4183647
commit 4104d637f1
5 changed files with 87 additions and 29 deletions

1
src/def.d.ts vendored
View file

@ -59,6 +59,7 @@ interface Plugin {
id: string;
manifest: PluginManifest;
enabled: boolean;
update: boolean;
js: string;
}

View file

@ -12,6 +12,7 @@ import { all, find, getAssetByID, getAssetByName, getAssetIDByName } from "@ui/a
import patchAssets from "@ui/assets";
import initSettings from "@ui/settings";
import { connectToDebugger, patchLogHook } from "@lib/debug";
import { initPlugins } from "@lib/plugins";
console.log("Hello from Vendetta!");
@ -48,6 +49,7 @@ async function init() {
initSettings();
patchAssets();
patchLogHook();
initPlugins();
} catch (e: Error | any) {
erroredOnLoad = true;
alert(`Vendetta failed to initialize... ${e.stack || e.toString()}`);

View file

@ -1,5 +1,6 @@
import { Indexable, PluginManifest, Plugin } from "@types";
import logger from "./logger";
import { AsyncStorage } from "@metro/common";
import logger from "@lib/logger";
type EvaledPlugin = {
onLoad?(): void;
@ -7,21 +8,43 @@ type EvaledPlugin = {
settings: JSX.Element;
};
export const plugins: Indexable<Plugin> = {};
const proxyValidator = {
get(target: object, key: string | symbol): any {
const orig = Reflect.get(target, key);
if (typeof orig === "object" && orig !== null) {
return new Proxy(orig, proxyValidator);
} else {
return orig;
}
},
set(target: object, key: string | symbol, value: any) {
Reflect.set(target, key, value);
AsyncStorage.setItem("VENDETTA_PLUGINS", JSON.stringify(plugins));
return true;
},
deleteProperty(target: object, key: string | symbol) {
Reflect.deleteProperty(target, key);
AsyncStorage.setItem("VENDETTA_PLUGINS", JSON.stringify(plugins));
return true;
}
}
export const plugins: Indexable<Plugin> = new Proxy({}, proxyValidator);
const loadedPlugins: Indexable<EvaledPlugin> = {};
export async function fetchPlugin(url: string) {
if (!url.endsWith("/")) url += "/";
const id = url.split("://")[1];
if (typeof url !== "string" || url in plugins) throw new Error("Plugin ID invalid or taken");
export async function fetchPlugin(id: string) {
if (!id.endsWith("/")) id += "/";
if (typeof id !== "string" || id in plugins) throw new Error("Plugin ID invalid or taken");
let pluginManifest: PluginManifest;
try {
pluginManifest = await (await fetch(new URL("manifest.json", url), { cache: "no-store" })).json();
pluginManifest = await (await fetch(new URL("manifest.json", id), { cache: "no-store" })).json();
} catch {
throw new Error(`Failed to fetch manifest for ${url}`);
throw new Error(`Failed to fetch manifest for ${id}`);
}
let pluginJs: string;
@ -29,17 +52,18 @@ export async function fetchPlugin(url: string) {
// TODO: Remove duplicate error if possible
try {
// by polymanifest spec, plugins should always specify their main file, but just in case
pluginJs = await (await fetch(new URL(pluginManifest.main || "index.js", url), { cache: "no-store" })).text();
pluginJs = await (await fetch(new URL(pluginManifest.main || "index.js", id), { cache: "no-store" })).text();
} catch {
throw new Error(`Failed to fetch JS for ${url}`);
throw new Error(`Failed to fetch JS for ${id}`);
}
if (pluginJs.length === 0) throw new Error(`Failed to fetch JS for ${url}`);
if (pluginJs.length === 0) throw new Error(`Failed to fetch JS for ${id}`);
plugins[id] = {
id: id,
manifest: pluginManifest,
enabled: false,
update: true,
js: pluginJs,
};
}
@ -93,7 +117,26 @@ export function stopPlugin(id: string) {
plugin.enabled = false;
}
export function removePlugin(id: string) {
stopPlugin(id);
delete plugins[id];
}
export const getSettings = (id: string) => loadedPlugins[id]?.settings;
// TODO: When startAllPlugins exists, return this so cleanup in index.ts is easier
const stopAllPlugins = () => Object.keys(loadedPlugins).forEach(stopPlugin);
export const initPlugins = () => AsyncStorage.getItem("VENDETTA_PLUGINS").then(async function (v) {
if (!v) return;
const parsedPlugins: Indexable<Plugin> = JSON.parse(v);
for (let p of Object.keys(parsedPlugins)) {
const plugin = parsedPlugins[p]
if (parsedPlugins[p].update) {
await fetchPlugin(plugin.id);
} else {
plugins[p] = parsedPlugins[p];
}
if (parsedPlugins[p].enabled && plugins[p]) startPlugin(p);
}
})

View file

@ -2,7 +2,8 @@ import { ReactNative as RN, stylesheet, navigation } from "@metro/common";
import { Forms, General } from "@ui/components";
import { Plugin } from "@types";
import { getAssetIDByName } from "@ui/assets";
import { getSettings, startPlugin, stopPlugin } from "@lib/plugins";
import { getSettings, removePlugin, startPlugin, stopPlugin } from "@lib/plugins";
import { showToast } from "@ui/toasts";
import PluginSettings from "@ui/settings/components/PluginSettings";
const { FormRow, FormSwitch } = Forms;
@ -27,6 +28,7 @@ const styles = stylesheet.createThemedStyleSheet({
icon: {
width: 22,
height: 22,
marginLeft: 5,
tintColor: stylesheet.ThemeColorMap.INTERACTIVE_NORMAL,
}
})
@ -37,8 +39,14 @@ interface PluginCardProps {
export default function PluginCard({ plugin }: PluginCardProps) {
const [enabled, setEnabled] = React.useState(plugin.enabled);
const [update, setUpdate] = React.useState(plugin.update);
const [removed, setRemoved] = React.useState(false);
const Settings = getSettings(plugin.id);
// This is bad, but I don't think I have much choice - Beef
// Once the user re-renders the page, this is not taken into account anyway.
if (removed) return <></>;
return (
<RN.View style={styles.card}>
<FormRow
@ -59,6 +67,23 @@ export default function PluginCard({ plugin }: PluginCardProps) {
label={plugin.manifest.description}
trailing={
<RN.View style={styles.actions}>
<TouchableOpacity
onPress={() => {
removePlugin(plugin.id);
setRemoved(true);
}}
>
<Image style={styles.icon} source={getAssetIDByName("ic_message_delete")} />
</TouchableOpacity>
<TouchableOpacity
onPress={() => {
plugin.update = !plugin.update;
setUpdate(plugin.update);
showToast(`${plugin.update ? "Enabled" : "Disabled"} updates for ${plugin.manifest.name}.`, getAssetIDByName("toast_image_saved"));
}}
>
<Image style={styles.icon} source={getAssetIDByName(plugin.update ? "Check" : "Small")} />
</TouchableOpacity>
{Settings && <TouchableOpacity
onPress={() => {
navigation.push(PluginSettings, {

View file

@ -5,17 +5,7 @@ import { getAssetIDByName } from "@ui/assets";
import { fetchPlugin, plugins } from "@lib/plugins";
import PluginCard from "@ui/settings/components/PluginCard";
const { FormInput, FormRow, FormText } = Forms;
const styles = stylesheet.createThemedStyleSheet({
disclaimer: {
backgroundColor: stylesheet.ThemeColorMap.BACKGROUND_SECONDARY,
padding: 10
},
disclaimerText: {
textAlign: "center"
}
})
const { FormInput, FormRow } = Forms;
export default function Plugins() {
const [pluginUrl, setPluginUrl] = React.useState("");
@ -48,9 +38,6 @@ export default function Plugins() {
renderItem={({ item }) => <PluginCard plugin={item} />}
keyExtractor={item => item.id}
/>
<RN.View style={styles.disclaimer}>
<FormText style={styles.disclaimerText}>Plugins are currently non-permanent whilst I find a storage solution.</FormText>
</RN.View>
</>
)
}