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
|
package plugin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"kvm/internal/storage"
|
"kvm/internal/storage"
|
||||||
"os"
|
"os"
|
||||||
|
@ -10,7 +11,7 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
const pluginsFolder = "/userdata/jetkvm/plugins"
|
const pluginsFolder = "/userdata/jetkvm/plugins"
|
||||||
const pluginsUploadFolder = pluginsFolder + "/_uploads"
|
const pluginsUploadFolder = pluginsFolder + "/uploads"
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
_ = os.MkdirAll(pluginsUploadFolder, 0755)
|
_ = os.MkdirAll(pluginsUploadFolder, 0755)
|
||||||
|
@ -51,3 +52,122 @@ func RpcPluginStartUpload(filename string, size int64) (*storage.StorageFileUplo
|
||||||
DataChannel: uploadId,
|
DataChannel: uploadId,
|
||||||
}, nil
|
}, 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"}},
|
"setWakeOnLanDevices": {Func: rpcSetWakeOnLanDevices, Params: []string{"params"}},
|
||||||
"resetConfig": {Func: rpcResetConfig},
|
"resetConfig": {Func: rpcResetConfig},
|
||||||
"pluginStartUpload": {Func: plugin.RpcPluginStartUpload, Params: []string{"filename", "size"}},
|
"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 LogoWhiteIcon from "@/assets/logo-white.svg";
|
||||||
import Modal from "@components/Modal";
|
import Modal from "@components/Modal";
|
||||||
import {
|
import {
|
||||||
|
PluginManifest,
|
||||||
|
usePluginStore,
|
||||||
useRTCStore,
|
useRTCStore,
|
||||||
} from "../hooks/stores";
|
} from "../hooks/stores";
|
||||||
import { cx } from "../cva.config";
|
import { cx } from "../cva.config";
|
||||||
|
@ -16,6 +18,7 @@ import { formatters } from "@/utils";
|
||||||
import { PlusCircleIcon } from "@heroicons/react/20/solid";
|
import { PlusCircleIcon } from "@heroicons/react/20/solid";
|
||||||
import AutoHeight from "./AutoHeight";
|
import AutoHeight from "./AutoHeight";
|
||||||
import { useJsonRpc } from "../hooks/useJsonRpc";
|
import { useJsonRpc } from "../hooks/useJsonRpc";
|
||||||
|
import { ExclamationTriangleIcon } from "@heroicons/react/20/solid";
|
||||||
import notifications from "../notifications";
|
import notifications from "../notifications";
|
||||||
import { isOnDevice } from "../main";
|
import { isOnDevice } from "../main";
|
||||||
import { ViewHeader } from "./MountMediaDialog";
|
import { ViewHeader } from "./MountMediaDialog";
|
||||||
|
@ -35,6 +38,28 @@ export default function UploadPluginModal({
|
||||||
}
|
}
|
||||||
|
|
||||||
function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
|
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 (
|
return (
|
||||||
<AutoHeight>
|
<AutoHeight>
|
||||||
<div
|
<div
|
||||||
|
@ -54,14 +79,46 @@ function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
|
||||||
className="h-[24px] dark:block hidden dark:!mt-0"
|
className="h-[24px] dark:block hidden dark:!mt-0"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<UploadFileView
|
{!extractError && pluginUploadModalView === "upload" && <UploadFileView
|
||||||
onBack={() => {
|
onBack={() => {
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
}}
|
}}
|
||||||
onCancelUpload={() => {
|
onUploadCompleted={(filename) => {
|
||||||
setOpen(false)
|
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>
|
||||||
</div>
|
</div>
|
||||||
</GridCard>
|
</GridCard>
|
||||||
|
@ -74,10 +131,10 @@ function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
|
||||||
// TODO: refactor to a shared component
|
// TODO: refactor to a shared component
|
||||||
function UploadFileView({
|
function UploadFileView({
|
||||||
onBack,
|
onBack,
|
||||||
onCancelUpload,
|
onUploadCompleted,
|
||||||
}: {
|
}: {
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
onCancelUpload: () => void;
|
onUploadCompleted: (filename: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const [uploadState, setUploadState] = useState<"idle" | "uploading" | "success">(
|
const [uploadState, setUploadState] = useState<"idle" | "uploading" | "success">(
|
||||||
"idle",
|
"idle",
|
||||||
|
@ -177,6 +234,7 @@ function UploadFileView({
|
||||||
if (offset >= file.size) {
|
if (offset >= file.size) {
|
||||||
rtcDataChannel.close();
|
rtcDataChannel.close();
|
||||||
setUploadState("success");
|
setUploadState("success");
|
||||||
|
onUploadCompleted(file.name);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -260,6 +318,7 @@ function UploadFileView({
|
||||||
xhr.onload = () => {
|
xhr.onload = () => {
|
||||||
if (xhr.status === 200) {
|
if (xhr.status === 200) {
|
||||||
setUploadState("success");
|
setUploadState("success");
|
||||||
|
onUploadCompleted(file.name);
|
||||||
} else {
|
} else {
|
||||||
console.error("Upload error:", xhr.statusText);
|
console.error("Upload error:", xhr.statusText);
|
||||||
setUploadError(xhr.statusText);
|
setUploadError(xhr.statusText);
|
||||||
|
@ -459,7 +518,7 @@ function UploadFileView({
|
||||||
theme="light"
|
theme="light"
|
||||||
text="Cancel Upload"
|
text="Cancel Upload"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onCancelUpload();
|
onBack();
|
||||||
setUploadState("idle");
|
setUploadState("idle");
|
||||||
setUploadProgress(0);
|
setUploadProgress(0);
|
||||||
setUploadedFileName(null);
|
setUploadedFileName(null);
|
||||||
|
@ -470,7 +529,7 @@ function UploadFileView({
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
size="MD"
|
size="MD"
|
||||||
theme={uploadState === "success" ? "primary" : "light"}
|
theme="light"
|
||||||
text="Back"
|
text="Back"
|
||||||
onClick={onBack}
|
onClick={onBack}
|
||||||
/>
|
/>
|
||||||
|
@ -480,3 +539,133 @@ 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(() => {
|
useEffect(() => {
|
||||||
getCloudState();
|
getCloudState();
|
||||||
|
@ -776,7 +776,10 @@ export default function SettingsSidebar() {
|
||||||
size="SM"
|
size="SM"
|
||||||
theme="primary"
|
theme="primary"
|
||||||
text="Upload Plugin"
|
text="Upload Plugin"
|
||||||
onClick={() => setIsPluginUploadModalOpen(true)}
|
onClick={() => {
|
||||||
|
setPluginUploadModalView("upload");
|
||||||
|
setIsPluginUploadModalOpen(true)
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<UploadPluginModal
|
<UploadPluginModal
|
||||||
open={isPluginUploadModalOpen}
|
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 {
|
interface PluginState {
|
||||||
isPluginUploadModalOpen: boolean;
|
isPluginUploadModalOpen: boolean;
|
||||||
setIsPluginUploadModalOpen: (isOpen: boolean) => void;
|
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 => ({
|
export const usePluginStore = create<PluginState>(set => ({
|
||||||
isPluginUploadModalOpen: false,
|
isPluginUploadModalOpen: false,
|
||||||
setIsPluginUploadModalOpen: isOpen => set({ isPluginUploadModalOpen: isOpen }),
|
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