mirror of https://github.com/jetkvm/kvm.git
Add extracting and validating the plugin
This commit is contained in:
parent
377c3e89c0
commit
0a772005dc
|
@ -0,0 +1,54 @@
|
|||
package plugin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
const databaseFile = pluginsFolder + "/plugins.json"
|
||||
|
||||
var pluginDatabase = PluginDatabase{}
|
||||
|
||||
func init() {
|
||||
if err := pluginDatabase.Load(); err != nil {
|
||||
fmt.Printf("failed to load plugin database: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *PluginDatabase) Load() error {
|
||||
file, err := os.Open(databaseFile)
|
||||
if os.IsNotExist(err) {
|
||||
d.Plugins = make(map[string]PluginInstall)
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open plugin database: %v", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if err := json.NewDecoder(file).Decode(d); err != nil {
|
||||
return fmt.Errorf("failed to decode plugin database: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *PluginDatabase) Save() error {
|
||||
d.saveMutex.Lock()
|
||||
defer d.saveMutex.Unlock()
|
||||
|
||||
file, err := os.Create(databaseFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create plugin database: %v", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
encoder := json.NewEncoder(file)
|
||||
encoder.SetIndent("", " ")
|
||||
if err := encoder.Encode(d); err != nil {
|
||||
return fmt.Errorf("failed to encode plugin database: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
|
@ -0,0 +1,83 @@
|
|||
package plugin
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const pluginsExtractsFolder = pluginsFolder + "/extracts"
|
||||
|
||||
func init() {
|
||||
_ = os.MkdirAll(pluginsExtractsFolder, 0755)
|
||||
}
|
||||
|
||||
func extractPlugin(filePath string) (*string, error) {
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open file for extraction: %v", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var reader io.Reader = file
|
||||
// TODO: there's probably a better way of doing this without relying on the file extension
|
||||
if strings.HasSuffix(filePath, ".gz") {
|
||||
gzipReader, err := gzip.NewReader(file)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create gzip reader: %v", err)
|
||||
}
|
||||
defer gzipReader.Close()
|
||||
reader = gzipReader
|
||||
}
|
||||
|
||||
destinationFolder := path.Join(pluginsExtractsFolder, uuid.New().String())
|
||||
if err := os.MkdirAll(destinationFolder, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create extracts folder: %v", err)
|
||||
}
|
||||
|
||||
tarReader := tar.NewReader(reader)
|
||||
|
||||
for {
|
||||
header, err := tarReader.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read tar header: %v", err)
|
||||
}
|
||||
|
||||
// Prevent path traversal attacks
|
||||
targetPath := filepath.Join(destinationFolder, header.Name)
|
||||
if !strings.HasPrefix(targetPath, filepath.Clean(destinationFolder)+string(os.PathSeparator)) {
|
||||
return nil, fmt.Errorf("tar file contains illegal path: %s", header.Name)
|
||||
}
|
||||
|
||||
switch header.Typeflag {
|
||||
case tar.TypeDir:
|
||||
if err := os.MkdirAll(targetPath, os.FileMode(header.Mode)); err != nil {
|
||||
return nil, fmt.Errorf("failed to create directory: %v", err)
|
||||
}
|
||||
case tar.TypeReg:
|
||||
file, err := os.OpenFile(targetPath, os.O_CREATE|os.O_WRONLY, os.FileMode(header.Mode))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create file: %v", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if _, err := io.Copy(file, tarReader); err != nil {
|
||||
return nil, fmt.Errorf("failed to extract file: %v", err)
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported tar entry type: %v", header.Typeflag)
|
||||
}
|
||||
}
|
||||
|
||||
return &destinationFolder, nil
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
package plugin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"kvm/internal/storage"
|
||||
"os"
|
||||
|
@ -10,7 +11,7 @@ import (
|
|||
)
|
||||
|
||||
const pluginsFolder = "/userdata/jetkvm/plugins"
|
||||
const pluginsUploadFolder = pluginsFolder + "/_uploads"
|
||||
const pluginsUploadFolder = pluginsFolder + "/uploads"
|
||||
|
||||
func init() {
|
||||
_ = os.MkdirAll(pluginsUploadFolder, 0755)
|
||||
|
@ -51,3 +52,122 @@ func RpcPluginStartUpload(filename string, size int64) (*storage.StorageFileUplo
|
|||
DataChannel: uploadId,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func RpcPluginExtract(filename string) (*PluginManifest, error) {
|
||||
sanitizedFilename, err := storage.SanitizeFilename(filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
filePath := path.Join(pluginsUploadFolder, sanitizedFilename)
|
||||
extractFolder, err := extractPlugin(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := os.Remove(filePath); err != nil {
|
||||
return nil, fmt.Errorf("failed to delete uploaded file: %v", err)
|
||||
}
|
||||
|
||||
manifest, err := readManifest(*extractFolder)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Get existing PluginInstall
|
||||
install, ok := pluginDatabase.Plugins[manifest.Name]
|
||||
if !ok {
|
||||
install = PluginInstall{
|
||||
Enabled: false,
|
||||
Version: manifest.Version,
|
||||
ExtractedVersions: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
_, ok = install.ExtractedVersions[manifest.Version]
|
||||
if ok {
|
||||
return nil, fmt.Errorf("this version has already been uploaded: %s", manifest.Version)
|
||||
}
|
||||
|
||||
install.ExtractedVersions[manifest.Version] = *extractFolder
|
||||
pluginDatabase.Plugins[manifest.Name] = install
|
||||
|
||||
if err := pluginDatabase.Save(); err != nil {
|
||||
return nil, fmt.Errorf("failed to save plugin database: %v", err)
|
||||
}
|
||||
|
||||
return manifest, nil
|
||||
}
|
||||
|
||||
func RpcPluginInstall(name string, version string) error {
|
||||
// TODO: find the plugin version in the plugins.json file
|
||||
pluginInstall, ok := pluginDatabase.Plugins[name]
|
||||
if !ok {
|
||||
return fmt.Errorf("plugin not found: %s", name)
|
||||
}
|
||||
|
||||
if pluginInstall.Version == version && pluginInstall.Enabled {
|
||||
fmt.Printf("Plugin %s is already installed with version %s\n", name, version)
|
||||
return nil
|
||||
}
|
||||
|
||||
_, ok = pluginInstall.ExtractedVersions[version]
|
||||
if !ok {
|
||||
return fmt.Errorf("plugin version not found: %s", version)
|
||||
}
|
||||
|
||||
// TODO: If there is a running plugin with the same name, stop it and start the new version
|
||||
|
||||
pluginInstall.Version = version
|
||||
pluginInstall.Enabled = true
|
||||
pluginDatabase.Plugins[name] = pluginInstall
|
||||
|
||||
if err := pluginDatabase.Save(); err != nil {
|
||||
return fmt.Errorf("failed to save plugin database: %v", err)
|
||||
}
|
||||
// TODO: start the plugin
|
||||
|
||||
// TODO: Determine if the old version should be removed
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func readManifest(extractFolder string) (*PluginManifest, error) {
|
||||
manifestPath := path.Join(extractFolder, "manifest.json")
|
||||
manifestFile, err := os.Open(manifestPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open manifest file: %v", err)
|
||||
}
|
||||
defer manifestFile.Close()
|
||||
|
||||
manifest := PluginManifest{}
|
||||
if err := json.NewDecoder(manifestFile).Decode(&manifest); err != nil {
|
||||
return nil, fmt.Errorf("failed to read manifest file: %v", err)
|
||||
}
|
||||
|
||||
if err := validateManifest(&manifest); err != nil {
|
||||
return nil, fmt.Errorf("invalid manifest file: %v", err)
|
||||
}
|
||||
|
||||
return &manifest, nil
|
||||
}
|
||||
|
||||
func validateManifest(manifest *PluginManifest) error {
|
||||
if manifest.ManifestVersion != "1" {
|
||||
return fmt.Errorf("unsupported manifest version: %s", manifest.ManifestVersion)
|
||||
}
|
||||
|
||||
if manifest.Name == "" {
|
||||
return fmt.Errorf("missing plugin name")
|
||||
}
|
||||
|
||||
if manifest.Version == "" {
|
||||
return fmt.Errorf("missing plugin version")
|
||||
}
|
||||
|
||||
if manifest.Homepage == "" {
|
||||
return fmt.Errorf("missing plugin homepage")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -0,0 +1,30 @@
|
|||
package plugin
|
||||
|
||||
import "sync"
|
||||
|
||||
type PluginManifest struct {
|
||||
ManifestVersion string `json:"manifest_version"`
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Homepage string `json:"homepage"`
|
||||
BinaryPath string `json:"bin"`
|
||||
SystemMinVersion string `json:"system_min_version,omitempty"`
|
||||
}
|
||||
|
||||
type PluginInstall struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
|
||||
// Current active version of the plugin
|
||||
Version string `json:"version"`
|
||||
|
||||
// Map of a plugin version to the extracted directory
|
||||
ExtractedVersions map[string]string `json:"extracted_versions"`
|
||||
}
|
||||
|
||||
type PluginDatabase struct {
|
||||
// Map with the plugin name as the key
|
||||
Plugins map[string]PluginInstall `json:"plugins"`
|
||||
|
||||
saveMutex sync.Mutex
|
||||
}
|
|
@ -556,4 +556,6 @@ var rpcHandlers = map[string]RPCHandler{
|
|||
"setWakeOnLanDevices": {Func: rpcSetWakeOnLanDevices, Params: []string{"params"}},
|
||||
"resetConfig": {Func: rpcResetConfig},
|
||||
"pluginStartUpload": {Func: plugin.RpcPluginStartUpload, Params: []string{"filename", "size"}},
|
||||
"pluginExtract": {Func: plugin.RpcPluginExtract, Params: []string{"filename"}},
|
||||
"pluginInstall": {Func: plugin.RpcPluginInstall, Params: []string{"name", "version"}},
|
||||
}
|
||||
|
|
|
@ -5,6 +5,8 @@ import LogoBlueIcon from "@/assets/logo-blue.svg";
|
|||
import LogoWhiteIcon from "@/assets/logo-white.svg";
|
||||
import Modal from "@components/Modal";
|
||||
import {
|
||||
PluginManifest,
|
||||
usePluginStore,
|
||||
useRTCStore,
|
||||
} from "../hooks/stores";
|
||||
import { cx } from "../cva.config";
|
||||
|
@ -16,6 +18,7 @@ import { formatters } from "@/utils";
|
|||
import { PlusCircleIcon } from "@heroicons/react/20/solid";
|
||||
import AutoHeight from "./AutoHeight";
|
||||
import { useJsonRpc } from "../hooks/useJsonRpc";
|
||||
import { ExclamationTriangleIcon } from "@heroicons/react/20/solid";
|
||||
import notifications from "../notifications";
|
||||
import { isOnDevice } from "../main";
|
||||
import { ViewHeader } from "./MountMediaDialog";
|
||||
|
@ -35,6 +38,28 @@ export default function UploadPluginModal({
|
|||
}
|
||||
|
||||
function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
|
||||
const {
|
||||
pluginUploadModalView,
|
||||
setPluginUploadModalView,
|
||||
pluginUploadFilename,
|
||||
setPluginUploadFilename,
|
||||
pluginUploadManifest,
|
||||
setPluginUploadManifest,
|
||||
} = usePluginStore();
|
||||
const [send] = useJsonRpc();
|
||||
const [extractError, setExtractError] = useState<string | null>(null);
|
||||
|
||||
function extractPlugin(filename: string) {
|
||||
send("pluginExtract", { filename }, resp => {
|
||||
if ("error" in resp) {
|
||||
setExtractError(resp.error.data || resp.error.message);
|
||||
return
|
||||
}
|
||||
|
||||
setPluginUploadManifest(resp.result as PluginManifest);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<AutoHeight>
|
||||
<div
|
||||
|
@ -54,14 +79,46 @@ function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
|
|||
className="h-[24px] dark:block hidden dark:!mt-0"
|
||||
/>
|
||||
|
||||
<UploadFileView
|
||||
{!extractError && pluginUploadModalView === "upload" && <UploadFileView
|
||||
onBack={() => {
|
||||
setOpen(false)
|
||||
}}
|
||||
onCancelUpload={() => {
|
||||
setOpen(false)
|
||||
onUploadCompleted={(filename) => {
|
||||
setPluginUploadFilename(filename)
|
||||
setPluginUploadModalView("install")
|
||||
extractPlugin(filename)
|
||||
}}
|
||||
/>
|
||||
/>}
|
||||
|
||||
{extractError && (
|
||||
<ErrorView
|
||||
errorMessage={extractError}
|
||||
onClose={() => {
|
||||
setOpen(false)
|
||||
setPluginUploadFilename(null)
|
||||
setExtractError(null)
|
||||
}}
|
||||
onRetry={() => {
|
||||
setExtractError(null)
|
||||
setPluginUploadFilename(null)
|
||||
setPluginUploadModalView("upload")
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!extractError && pluginUploadModalView === "install" && <InstallPluginView
|
||||
filename={pluginUploadFilename!}
|
||||
manifest={pluginUploadManifest}
|
||||
onInstall={() => {
|
||||
setOpen(false)
|
||||
setPluginUploadFilename(null)
|
||||
// TODO: Open plugin settings dialog
|
||||
}}
|
||||
onBack={() => {
|
||||
setPluginUploadModalView("upload")
|
||||
setPluginUploadFilename(null)
|
||||
}}
|
||||
/>}
|
||||
</div>
|
||||
</div>
|
||||
</GridCard>
|
||||
|
@ -74,10 +131,10 @@ function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
|
|||
// TODO: refactor to a shared component
|
||||
function UploadFileView({
|
||||
onBack,
|
||||
onCancelUpload,
|
||||
onUploadCompleted,
|
||||
}: {
|
||||
onBack: () => void;
|
||||
onCancelUpload: () => void;
|
||||
onUploadCompleted: (filename: string) => void;
|
||||
}) {
|
||||
const [uploadState, setUploadState] = useState<"idle" | "uploading" | "success">(
|
||||
"idle",
|
||||
|
@ -177,6 +234,7 @@ function UploadFileView({
|
|||
if (offset >= file.size) {
|
||||
rtcDataChannel.close();
|
||||
setUploadState("success");
|
||||
onUploadCompleted(file.name);
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -260,6 +318,7 @@ function UploadFileView({
|
|||
xhr.onload = () => {
|
||||
if (xhr.status === 200) {
|
||||
setUploadState("success");
|
||||
onUploadCompleted(file.name);
|
||||
} else {
|
||||
console.error("Upload error:", xhr.statusText);
|
||||
setUploadError(xhr.statusText);
|
||||
|
@ -459,7 +518,7 @@ function UploadFileView({
|
|||
theme="light"
|
||||
text="Cancel Upload"
|
||||
onClick={() => {
|
||||
onCancelUpload();
|
||||
onBack();
|
||||
setUploadState("idle");
|
||||
setUploadProgress(0);
|
||||
setUploadedFileName(null);
|
||||
|
@ -470,7 +529,7 @@ function UploadFileView({
|
|||
) : (
|
||||
<Button
|
||||
size="MD"
|
||||
theme={uploadState === "success" ? "primary" : "light"}
|
||||
theme="light"
|
||||
text="Back"
|
||||
onClick={onBack}
|
||||
/>
|
||||
|
@ -479,4 +538,134 @@ function UploadFileView({
|
|||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InstallPluginView({
|
||||
filename,
|
||||
manifest,
|
||||
onInstall,
|
||||
onBack,
|
||||
}: {
|
||||
filename: string;
|
||||
manifest: PluginManifest | null;
|
||||
onInstall: () => void;
|
||||
onBack: () => void;
|
||||
}) {
|
||||
const [send] = useJsonRpc();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [installing, setInstalling] = useState(false);
|
||||
|
||||
function handleInstall() {
|
||||
if (installing) return;
|
||||
setInstalling(true);
|
||||
send("pluginInstall", { name: manifest!.name, version: manifest!.version }, resp => {
|
||||
if ("error" in resp) {
|
||||
setError(resp.error.message);
|
||||
return
|
||||
}
|
||||
|
||||
setInstalling(false);
|
||||
onInstall();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-4">
|
||||
<ViewHeader
|
||||
title="Install Plugin"
|
||||
description={
|
||||
!manifest ?
|
||||
`Extracting plugin from ${filename}...` :
|
||||
`Do you want to install the plugin?`
|
||||
}
|
||||
/>
|
||||
{manifest && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm text-slate-700 dark:text-slate-300">
|
||||
<h3 className="text-lg font-semibold">{manifest.name}</h3>
|
||||
<p className="text-xs">{manifest.description}</p>
|
||||
<p className="text-xs">
|
||||
Version: {manifest.version}
|
||||
</p>
|
||||
<p className="text-xs">
|
||||
<a
|
||||
href={manifest.homepage}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-blue-500 dark:text-blue-400"
|
||||
>
|
||||
{manifest.homepage}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div
|
||||
className="mt-2 text-sm text-red-600 truncate opacity-0 dark:text-red-400 animate-fadeIn"
|
||||
style={{ animationDuration: "0.7s" }}
|
||||
>
|
||||
Error: {error}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="space-y-2 opacity-0 animate-fadeIn"
|
||||
style={{
|
||||
animationDuration: "0.7s",
|
||||
}}
|
||||
>
|
||||
<div className="flex justify-end w-full space-x-2">
|
||||
<Button
|
||||
size="MD"
|
||||
theme="light"
|
||||
text="Cancel"
|
||||
onClick={() => {
|
||||
// TODO: Delete the orphaned extraction
|
||||
setError(null);
|
||||
onBack();
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="MD"
|
||||
theme="primary"
|
||||
text="Install"
|
||||
onClick={handleInstall}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorView({
|
||||
errorMessage,
|
||||
onClose,
|
||||
onRetry,
|
||||
}: {
|
||||
errorMessage: string | null;
|
||||
onClose: () => void;
|
||||
onRetry: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="w-full space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2 text-red-600">
|
||||
<ExclamationTriangleIcon className="w-6 h-6" />
|
||||
<h2 className="text-lg font-bold leading-tight">Plugin Extract Error</h2>
|
||||
</div>
|
||||
<p className="text-sm leading-snug text-slate-600">
|
||||
An error occurred while attempting to extract the plugin. Please ensure the plugin is valid and try again.
|
||||
</p>
|
||||
</div>
|
||||
{errorMessage && (
|
||||
<Card className="p-4 border border-red-200 bg-red-50">
|
||||
<p className="text-sm font-medium text-red-800">{errorMessage}</p>
|
||||
</Card>
|
||||
)}
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button size="SM" theme="light" text="Close" onClick={onClose} />
|
||||
<Button size="SM" theme="primary" text="Back to Upload" onClick={onRetry} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -253,7 +253,7 @@ export default function SettingsSidebar() {
|
|||
}
|
||||
};
|
||||
|
||||
const {isPluginUploadModalOpen, setIsPluginUploadModalOpen} = usePluginStore();
|
||||
const {isPluginUploadModalOpen, setIsPluginUploadModalOpen, setPluginUploadModalView} = usePluginStore();
|
||||
|
||||
useEffect(() => {
|
||||
getCloudState();
|
||||
|
@ -776,7 +776,10 @@ export default function SettingsSidebar() {
|
|||
size="SM"
|
||||
theme="primary"
|
||||
text="Upload Plugin"
|
||||
onClick={() => setIsPluginUploadModalOpen(true)}
|
||||
onClick={() => {
|
||||
setPluginUploadModalView("upload");
|
||||
setIsPluginUploadModalOpen(true)
|
||||
}}
|
||||
/>
|
||||
<UploadPluginModal
|
||||
open={isPluginUploadModalOpen}
|
||||
|
|
|
@ -530,12 +530,37 @@ export const useLocalAuthModalStore = create<LocalAuthModalState>(set => ({
|
|||
}));
|
||||
|
||||
|
||||
export interface PluginManifest {
|
||||
name: string;
|
||||
version: string;
|
||||
description?: string;
|
||||
homepage: string;
|
||||
}
|
||||
|
||||
interface PluginState {
|
||||
isPluginUploadModalOpen: boolean;
|
||||
setIsPluginUploadModalOpen: (isOpen: boolean) => void;
|
||||
|
||||
pluginUploadFilename: string | null;
|
||||
setPluginUploadFilename: (filename: string | null) => void;
|
||||
|
||||
pluginUploadManifest: PluginManifest | null;
|
||||
setPluginUploadManifest: (manifest: PluginManifest | null) => void;
|
||||
|
||||
pluginUploadModalView: "upload" | "install";
|
||||
setPluginUploadModalView: (view: PluginState["pluginUploadModalView"]) => void;
|
||||
}
|
||||
|
||||
export const usePluginStore = create<PluginState>(set => ({
|
||||
isPluginUploadModalOpen: false,
|
||||
setIsPluginUploadModalOpen: isOpen => set({ isPluginUploadModalOpen: isOpen }),
|
||||
}));
|
||||
|
||||
pluginUploadFilename: null,
|
||||
setPluginUploadFilename: filename => set({ pluginUploadFilename: filename }),
|
||||
|
||||
pluginUploadManifest: null,
|
||||
setPluginUploadManifest: manifest => set({ pluginUploadManifest: manifest }),
|
||||
|
||||
pluginUploadModalView: "upload",
|
||||
setPluginUploadModalView: view => set({ pluginUploadModalView: view }),
|
||||
}));
|
||||
|
|
Loading…
Reference in New Issue