diff --git a/internal/plugin/database.go b/internal/plugin/database.go index 2f9a89f..f97e748 100644 --- a/internal/plugin/database.go +++ b/internal/plugin/database.go @@ -4,10 +4,18 @@ import ( "encoding/json" "fmt" "os" + "sync" ) const databaseFile = pluginsFolder + "/plugins.json" +type PluginDatabase struct { + // Map with the plugin name as the key + Plugins map[string]PluginInstall `json:"plugins"` + + saveMutex sync.Mutex +} + var pluginDatabase = PluginDatabase{} func init() { diff --git a/internal/plugin/install.go b/internal/plugin/install.go new file mode 100644 index 0000000..c860aff --- /dev/null +++ b/internal/plugin/install.go @@ -0,0 +1,31 @@ +package plugin + +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"` + + manifest *PluginManifest +} + +func (p *PluginInstall) GetManifest() (*PluginManifest, error) { + if p.manifest != nil { + return p.manifest, nil + } + + manifest, err := readManifest(p.GetExtractedFolder()) + if err != nil { + return nil, err + } + + p.manifest = manifest + return manifest, nil +} + +func (p *PluginInstall) GetExtractedFolder() string { + return p.ExtractedVersions[p.Version] +} diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go index 3a3318d..b841f01 100644 --- a/internal/plugin/plugin.go +++ b/internal/plugin/plugin.go @@ -132,6 +132,28 @@ func RpcPluginInstall(name string, version string) error { return nil } +func RpcPluginList() ([]PluginStatus, error) { + plugins := make([]PluginStatus, 0, len(pluginDatabase.Plugins)) + for pluginName, plugin := range pluginDatabase.Plugins { + manifest, err := plugin.GetManifest() + if err != nil { + return nil, fmt.Errorf("failed to get plugin manifest for %s: %v", pluginName, err) + } + + status := "stopped" + if plugin.Enabled { + status = "running" + } + + plugins = append(plugins, PluginStatus{ + PluginManifest: *manifest, + Enabled: plugin.Enabled, + Status: status, + }) + } + return plugins, nil +} + func readManifest(extractFolder string) (*PluginManifest, error) { manifestPath := path.Join(extractFolder, "manifest.json") manifestFile, err := os.Open(manifestPath) diff --git a/internal/plugin/type.go b/internal/plugin/type.go index 6f07c59..01d85a5 100644 --- a/internal/plugin/type.go +++ b/internal/plugin/type.go @@ -1,7 +1,5 @@ package plugin -import "sync" - type PluginManifest struct { ManifestVersion string `json:"manifest_version"` Name string `json:"name"` @@ -12,19 +10,8 @@ type PluginManifest struct { 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 +type PluginStatus struct { + PluginManifest + Enabled bool `json:"enabled"` + Status string `json:"status"` } diff --git a/jsonrpc.go b/jsonrpc.go index 6ffdd87..9f3a9b2 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -558,4 +558,5 @@ var rpcHandlers = map[string]RPCHandler{ "pluginStartUpload": {Func: plugin.RpcPluginStartUpload, Params: []string{"filename", "size"}}, "pluginExtract": {Func: plugin.RpcPluginExtract, Params: []string{"filename"}}, "pluginInstall": {Func: plugin.RpcPluginInstall, Params: []string{"name", "version"}}, + "pluginList": {Func: plugin.RpcPluginList}, } diff --git a/ui/src/components/PluginList.tsx b/ui/src/components/PluginList.tsx new file mode 100644 index 0000000..947e618 --- /dev/null +++ b/ui/src/components/PluginList.tsx @@ -0,0 +1,107 @@ +import { useJsonRpc } from "@/hooks/useJsonRpc"; +import { Button } from "@components/Button"; +import { PluginStatus, usePluginStore, useUiStore } from "@/hooks/stores"; +import { useCallback, useEffect, useState } from "react"; +import { cx } from "@/cva.config"; +import UploadPluginModal from "@components/UploadPluginDialog"; + +function PluginListStatusIcon({ plugin }: { plugin: PluginStatus }) { + let classNames = "bg-slate-500 border-slate-600"; + if (plugin.enabled && plugin.status === "running") { + classNames = "bg-green-500 border-green-600"; + } else if (plugin.enabled && plugin.status === "stopped") { + classNames = "bg-red-500 border-red-600"; + } + + return ( +
+
+
+ ) +} + +export default function PluginList() { + const [send] = useJsonRpc(); + const [error, setError] = useState(null); + + const { + isPluginUploadModalOpen, + setIsPluginUploadModalOpen, + setPluginUploadModalView, + plugins, + setPlugins + } = usePluginStore(); + const sidebarView = useUiStore(state => state.sidebarView); + + const updatePlugins = useCallback(() => { + send("pluginList", {}, resp => { + if ("error" in resp) { + setError(resp.error.message); + return + } + setPlugins(resp.result as PluginStatus[]); + }); + }, [send, setPlugins]) + + useEffect(() => { + // Only update plugins when the sidebar view is the settings view + if (sidebarView !== "system") return; + updatePlugins(); + + const updateInterval = setInterval(() => { + updatePlugins(); + }, 10_000); + return () => clearInterval(updateInterval); + }, [updatePlugins, sidebarView]) + + return ( + <> +
+
    + {error &&
  • {error}
  • } + {plugins.length === 0 &&
  • No plugins installed
  • } + {plugins.map(plugin => ( +
  • + +
    +

    {plugin.name}

    +

    + {plugin.homepage} +

    +
    +
    +
    +
  • + ))} +
+
+ +
+
+ + ); +} \ No newline at end of file diff --git a/ui/src/components/sidebar/settings.tsx b/ui/src/components/sidebar/settings.tsx index 769b943..84170d0 100644 --- a/ui/src/components/sidebar/settings.tsx +++ b/ui/src/components/sidebar/settings.tsx @@ -4,7 +4,6 @@ import { useSettingsStore, useUiStore, useUpdateStore, - usePluginStore, } from "@/hooks/stores"; import { Checkbox } from "@components/Checkbox"; import { Button, LinkButton } from "@components/Button"; @@ -26,7 +25,7 @@ import LocalAuthPasswordDialog from "@/components/LocalAuthPasswordDialog"; import { LocalDevice } from "@routes/devices.$id"; import { useRevalidator } from "react-router-dom"; import { ShieldCheckIcon } from "@heroicons/react/20/solid"; -import UploadPluginModal from "@components/UploadPluginDialog"; +import PluginList from "@components/PluginList"; export function SettingsItem({ title, @@ -253,8 +252,6 @@ export default function SettingsSidebar() { } }; - const {isPluginUploadModalOpen, setIsPluginUploadModalOpen, setPluginUploadModalView} = usePluginStore(); - useEffect(() => { getCloudState(); @@ -752,40 +749,7 @@ export default function SettingsSidebar() { title="Plugins" description="Manage installed plugins and their settings" /> - -
-
+
diff --git a/ui/src/hooks/stores.ts b/ui/src/hooks/stores.ts index 9acaaf7..e0543ec 100644 --- a/ui/src/hooks/stores.ts +++ b/ui/src/hooks/stores.ts @@ -537,6 +537,11 @@ export interface PluginManifest { homepage: string; } +export interface PluginStatus extends PluginManifest { + enabled: boolean; + status: "stopped" | "running"; +} + interface PluginState { isPluginUploadModalOpen: boolean; setIsPluginUploadModalOpen: (isOpen: boolean) => void; @@ -549,6 +554,9 @@ interface PluginState { pluginUploadModalView: "upload" | "install"; setPluginUploadModalView: (view: PluginState["pluginUploadModalView"]) => void; + + plugins: PluginStatus[]; + setPlugins: (plugins: PluginStatus[]) => void; } export const usePluginStore = create(set => ({ @@ -563,4 +571,7 @@ export const usePluginStore = create(set => ({ pluginUploadModalView: "upload", setPluginUploadModalView: view => set({ pluginUploadModalView: view }), + + plugins: [], + setPlugins: plugins => set({ plugins }), }));